History

Add source control discovery state

Julius Marminge committed 6 months ago

Commit b133b0d1 parent e36a15513 files · +943 −124Browse files

Unified · Split

13 files
  1. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +147 −9
  2. apps/server/src/sourceControl/SourceControlDiscovery.ts +257 −15
  3. apps/web/src/components/settings/ProviderInstanceCard.tsx +8 −44
  4. apps/web/src/components/settings/RedactedSensitiveText.tsx +61 −0
  5. apps/web/src/components/settings/SourceControlSettings.tsx +91 −55
  6. apps/web/src/lib/sourceControlDiscoveryState.ts +44 −0
  7. apps/web/src/localApi.ts +2 −0
  8. bun.lock +1 −0
  9. packages/client-runtime/package.json +2 −1
  10. packages/client-runtime/src/index.ts +1 −0
  11. packages/client-runtime/src/sourceControlDiscoveryState.test.ts +107 −0
  12. packages/client-runtime/src/sourceControlDiscoveryState.ts +206 −0
  13. packages/contracts/src/sourceControl.ts +16 −0
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+147−9
@@ -1,6 +1,6 @@
11 import { assert, it } from "@effect/vitest";
22 import * as NodeServices from "@effect/platform-node/NodeServices";
3−import { Effect, Layer } from "effect";
3+import { Effect, Layer, Option } from "effect";
44 import { ChildProcessSpawner } from "effect/unstable/process";
55 import { VcsProcessSpawnError } from "@t3tools/contracts";
66
@@ -8,10 +8,16 @@ import { ServerConfig } from "../config.ts"; · 1 unmodified line
88 import * as VcsProcess from "../vcs/VcsProcess.ts";
99 import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";
1010
11−const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
12− exitCode: ChildProcessSpawner.ExitCode(0),
11+const processOutput = (
12+ stdout: string,
13+ options?: {
14+ readonly stderr?: string;
15+ readonly exitCode?: ChildProcessSpawner.ExitCode;
16+ },
17+): VcsProcess.VcsProcessOutput => ({
18+ exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
1319 stdout,
14− stderr: "",
20+ stderr: options?.stderr ?? "",
1521 stdoutTruncated: false,
1622 stderrTruncated: false,
1723 });
@@ -27,9 +33,20 @@ it.effect("reports implemented tools separately from locally available CLIs", () · 9 unmodified lines
2733 if (input.command === "git") {
2834 return Effect.succeed(processOutput("git version 2.51.0\n"));
2935 }
30− if (input.command === "gh") {
36+ if (input.command === "gh" && input.args[0] === "--version") {
3137 return Effect.succeed(processOutput("gh version 2.83.0\n"));
3238 }
39+ if (input.command === "gh" && input.args.join(" ") === "auth status") {
40+ return Effect.succeed(
41+ processOutput(`github.com
42+Logged in to github.com account juliusmarminge (keyring)
43+- Active account: true
44+- Git operations protocol: ssh
45+- Token: gho_************************************
46+- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'
47+`),
48+ );
49+ }
3350 return Effect.fail(
3451 new VcsProcessSpawnError({
3552 operation: input.operation,
@@ -65,12 +82,133 @@ it.effect("reports implemented tools separately from locally available CLIs", () · 29 unmodified lines
6582 kind: item.kind,
6683 implemented: item.implemented,
6784 status: item.status,
85+ auth: item.auth.status,
86+ account: item.auth.account,
87+ })),
88+ [
89+ {
90+ kind: "github",
91+ implemented: true,
92+ status: "available",
93+ auth: "authenticated",
94+ account: Option.some("juliusmarminge"),
95+ },
96+ {
97+ kind: "gitlab",
98+ implemented: false,
99+ status: "missing",
100+ auth: "unknown",
101+ account: Option.none(),
102+ },
103+ {
104+ kind: "azure-devops",
105+ implemented: false,
106+ status: "missing",
107+ auth: "unknown",
108+ account: Option.none(),
109+ },
110+ {
111+ kind: "bitbucket",
112+ implemented: false,
113+ status: "missing",
114+ auth: "unknown",
115+ account: Option.none(),
116+ },
117+ ],
118+ );
119+ }).pipe(Effect.provide(testLayer));
120+});
121+
122+it.effect("probes provider authentication without exposing token details", () => {
123+ const testLayer = layer.pipe(
124+ Layer.provide(
125+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }),
126+ ),
127+ Layer.provide(
128+ Layer.mock(VcsProcess.VcsProcess)({
129+ run: (input) => {
130+ if (input.args[0] === "--version") {
131+ return Effect.succeed(processOutput(`${input.command} version test\n`));
132+ }
133+ if (input.command === "gh" && input.args.join(" ") === "auth status") {
134+ return Effect.succeed(
135+ processOutput(`github.com
136+Logged in to github.com account octocat (keyring)
137+- Token: gho_************************************
138+- Token scopes: 'repo'
139+`),
140+ );
141+ }
142+ if (input.command === "glab" && input.args.join(" ") === "auth status") {
143+ return Effect.succeed(
144+ processOutput(`gitlab.com
145+Logged in to gitlab.com as gitlab-user
146+`),
147+ );
148+ }
149+ if (
150+ input.command === "az" &&
151+ input.args.join(" ") === "account show --query user.name -o tsv"
152+ ) {
153+ return Effect.succeed(processOutput("azure-user@example.com\n"));
154+ }
155+ if (input.command === "bb" && input.args.join(" ") === "auth status") {
156+ return Effect.succeed(
157+ processOutput(`bitbucket.org
158+Logged in as bitbucket-user
159+`),
160+ );
161+ }
162+ return Effect.fail(
163+ new VcsProcessSpawnError({
164+ operation: input.operation,
165+ command: input.command,
166+ cwd: input.cwd,
167+ cause: new Error(`${input.command} not found`),
168+ }),
169+ );
170+ },
171+ }),
172+ ),
173+ Layer.provideMerge(NodeServices.layer),
174+ );
175+
176+ return Effect.gen(function* () {
177+ const discovery = yield* SourceControlDiscovery;
178+ const result = yield* discovery.discover;
179+
180+ assert.deepStrictEqual(
181+ result.sourceControlProviders.map((item) => ({
182+ kind: item.kind,
183+ auth: item.auth.status,
184+ account: item.auth.account,
185+ detail: item.auth.detail,
68186 })),
69187 [
70− { kind: "github", implemented: true, status: "available" },
71− { kind: "gitlab", implemented: false, status: "missing" },
72− { kind: "azure-devops", implemented: false, status: "missing" },
73− { kind: "bitbucket", implemented: false, status: "missing" },
188+ {
189+ kind: "github",
190+ auth: "authenticated",
191+ account: Option.some("octocat"),
192+ detail: Option.none(),
193+ },
194+ {
195+ kind: "gitlab",
196+ auth: "authenticated",
197+ account: Option.some("gitlab-user"),
198+ detail: Option.none(),
199+ },
200+ {
201+ kind: "azure-devops",
202+ auth: "authenticated",
203+ account: Option.some("azure-user@example.com"),
204+ detail: Option.none(),
205+ },
206+ {
207+ kind: "bitbucket",
208+ auth: "authenticated",
209+ account: Option.some("bitbucket-user"),
210+ detail: Option.none(),
211+ },
74212 ],
75213 );
76214 }).pipe(Effect.provide(testLayer));
apps/server/src/sourceControl/SourceControlDiscovery.ts+257−15
@@ -1,4 +1,5 @@
11 import {
2+ type SourceControlProviderAuth,
23 type SourceControlDiscoveryResult,
34 type SourceControlProviderDiscoveryItem,
45 type SourceControlProviderKind,
@@ -24,8 +25,27 @@ type VcsProbe = DiscoveryProbe & { · 19 unmodified lines
2425
2526 type ProviderProbe = DiscoveryProbe & {
2627 readonly kind: SourceControlProviderKind;
28+ readonly authArgs: ReadonlyArray<string>;
29+ readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
2730 };
2831
32+interface AuthProbeInput {
33+ readonly stdout: string;
34+ readonly stderr: string;
35+ readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
36+}
37+
38+interface DiscoveryProbeResult<Kind extends string> {
39+ readonly kind: Kind;
40+ readonly label: string;
41+ readonly executable: string;
42+ readonly implemented: boolean;
43+ readonly status: "available" | "missing";
44+ readonly version: Option.Option<string>;
45+ readonly installHint: string;
46+ readonly detail: Option.Option<string>;
47+}
48+
2949 const VCS_PROBES: ReadonlyArray<VcsProbe> = [
3050 {
3151 kind: "git",
@@ -59,6 +79,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 27 unmodified lines
5979 label: "GitHub",
6080 executable: "gh",
6181 versionArgs: ["--version"],
82+ authArgs: ["auth", "status"],
83+ parseAuth: parseGitHubAuth,
6284 implemented: true,
6385 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
6486 },
@@ -67,6 +89,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 2 unmodified lines
6789 label: "GitLab",
6890 executable: "glab",
6991 versionArgs: ["--version"],
92+ authArgs: ["auth", "status"],
93+ parseAuth: parseGitLabAuth,
7094 implemented: false,
7195 installHint:
7296 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
@@ -76,6 +100,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 3 unmodified lines
76100 label: "Azure DevOps",
77101 executable: "az",
78102 versionArgs: ["--version"],
103+ authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
104+ parseAuth: parseAzureAuth,
79105 implemented: false,
80106 installHint:
81107 "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",
@@ -85,6 +111,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 3 unmodified lines
85111 label: "Bitbucket",
86112 executable: "bb",
87113 versionArgs: ["--version"],
114+ authArgs: ["auth", "status"],
115+ parseAuth: parseBitbucketAuth,
88116 implemented: false,
89117 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
90118 },
@@ -105,6 +133,178 @@ function detailFromCause(cause: unknown): Option.Option<string> { · 14 unmodified lines
105133 return Option.none();
106134 }
107135
136+function authAccount(account: string | undefined): Option.Option<string> {
137+ const trimmed = account?.trim();
138+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
139+}
140+
141+function authHost(host: string | undefined): Option.Option<string> {
142+ const trimmed = host?.trim();
143+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
144+}
145+
146+function authDetail(detail: string | undefined): Option.Option<string> {
147+ const trimmed = detail?.trim();
148+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
149+}
150+
151+function providerAuth(input: {
152+ readonly status: SourceControlProviderAuth["status"];
153+ readonly account?: string | undefined;
154+ readonly host?: string | undefined;
155+ readonly detail?: string | undefined;
156+}): SourceControlProviderAuth {
157+ return {
158+ status: input.status,
159+ account: authAccount(input.account),
160+ host: authHost(input.host),
161+ detail: authDetail(input.detail),
162+ };
163+}
164+
165+function unknownAuth(detail?: string): SourceControlProviderAuth {
166+ return providerAuth({ status: "unknown", detail });
167+}
168+
169+function combinedAuthOutput(input: AuthProbeInput): string {
170+ return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
171+}
172+
173+function sanitizedAuthLines(text: string): ReadonlyArray<string> {
174+ return text
175+ .split(/\r?\n/)
176+ .map((entry) => entry.trim())
177+ .filter((entry) => entry.length > 0)
178+ .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry));
179+}
180+
181+function firstSafeAuthLine(text: string): string | undefined {
182+ return sanitizedAuthLines(text)[0];
183+}
184+
185+function parseCliHost(text: string): string | undefined {
186+ return sanitizedAuthLines(text)
187+ .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
188+ .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
189+}
190+
191+function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
192+ for (const pattern of patterns) {
193+ const match = pattern.exec(text);
194+ const value = match?.[1]?.trim();
195+ if (value && value.length > 0) return value;
196+ }
197+ return undefined;
198+}
199+
200+function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth {
201+ const output = combinedAuthOutput(input);
202+ const account = matchFirst(output, [
203+ /Logged in to .* account\s+([^\s(]+)/iu,
204+ /Logged in to .* as\s+([^\s(]+)/iu,
205+ ]);
206+ const host = parseCliHost(output);
207+
208+ if (input.exitCode !== 0) {
209+ return providerAuth({
210+ status: "unauthenticated",
211+ host,
212+ detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
213+ });
214+ }
215+
216+ if (account) {
217+ return providerAuth({ status: "authenticated", account, host });
218+ }
219+
220+ return providerAuth({
221+ status: "unknown",
222+ host,
223+ detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
224+ });
225+}
226+
227+function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth {
228+ const output = combinedAuthOutput(input);
229+ const account = matchFirst(output, [
230+ /Logged in to .* as\s+([^\s(]+)/iu,
231+ /Logged in to .* account\s+([^\s(]+)/iu,
232+ /account:\s*([^\s(]+)/iu,
233+ ]);
234+ const host = parseCliHost(output);
235+
236+ if (input.exitCode !== 0) {
237+ return providerAuth({
238+ status: "unauthenticated",
239+ host,
240+ detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
241+ });
242+ }
243+
244+ if (account) {
245+ return providerAuth({ status: "authenticated", account, host });
246+ }
247+
248+ return providerAuth({
249+ status: "unknown",
250+ host,
251+ detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
252+ });
253+}
254+
255+function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth {
256+ const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
257+
258+ if (input.exitCode !== 0) {
259+ return providerAuth({
260+ status: "unauthenticated",
261+ detail:
262+ firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
263+ });
264+ }
265+
266+ if (account && account.length > 0) {
267+ return providerAuth({ status: "authenticated", account, host: "dev.azure.com" });
268+ }
269+
270+ return providerAuth({
271+ status: "unknown",
272+ host: "dev.azure.com",
273+ detail: "Azure CLI account status could not be parsed.",
274+ });
275+}
276+
277+function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth {
278+ const output = combinedAuthOutput(input);
279+ const account = matchFirst(output, [
280+ /Logged in to .* as\s+([^\s(]+)/iu,
281+ /Logged in as\s+([^\s(]+)/iu,
282+ /account:\s*([^\s(]+)/iu,
283+ /user:\s*([^\s(]+)/iu,
284+ /username:\s*([^\s(]+)/iu,
285+ ]);
286+ const host = parseCliHost(output) ?? "bitbucket.org";
287+
288+ if (input.exitCode !== 0) {
289+ return providerAuth({
290+ status: "unauthenticated",
291+ host,
292+ detail:
293+ firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.",
294+ });
295+ }
296+
297+ if (account) {
298+ return providerAuth({ status: "authenticated", account, host });
299+ }
300+
301+ return providerAuth({
302+ status: "unknown",
303+ host,
304+ detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.",
305+ });
306+}
307+
108308 export interface SourceControlDiscoveryShape {
109309 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
110310 }
@@ -122,7 +322,7 @@ export const layer = Layer.effect( · 11 unmodified lines
122322
123323 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
124324 input: DiscoveryProbe & { readonly kind: Kind },
125− ) =>
325+ ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
126326 process
127327 .run({
128328 operation: "source-control.discovery.probe",
@@ -134,18 +334,21 @@ export const layer = Layer.effect( · 5 unmodified lines
134334 truncateOutputAtMaxBytes: true,
135335 })
136336 .pipe(
137− Effect.map((result) => ({
138− kind: input.kind,
139− label: input.label,
140− executable: input.executable,
141− implemented: input.implemented,
142− status: "available" as const,
143− version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
144− firstNonEmptyLine(result.stderr),
145− ),
146− installHint: input.installHint,
147− detail: Option.none<string>(),
148− })),
337+ Effect.map(
338+ (result) =>
339+ ({
340+ kind: input.kind,
341+ label: input.label,
342+ executable: input.executable,
343+ implemented: input.implemented,
344+ status: "available" as const,
345+ version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
346+ firstNonEmptyLine(result.stderr),
347+ ),
348+ installHint: input.installHint,
349+ detail: Option.none<string>(),
350+ }) satisfies DiscoveryProbeResult<Kind>,
351+ ),
149352 Effect.catch((cause) =>
150353 Effect.succeed({
151354 kind: input.kind,
@@ -156,10 +359,49 @@ export const layer = Layer.effect( · 4 unmodified lines
156359 version: Option.none<string>(),
157360 installHint: input.installHint,
158361 detail: detailFromCause(cause),
159− }),
362+ } satisfies DiscoveryProbeResult<Kind>),
160363 ),
161364 );
162365
366+ const probeProvider = (input: ProviderProbe) =>
367+ probe(input).pipe(
368+ Effect.flatMap((item) => {
369+ if (item.status !== "available") {
370+ return Effect.succeed({
371+ ...item,
372+ auth: unknownAuth("CLI is not installed."),
373+ } satisfies SourceControlProviderDiscoveryItem);
374+ }
375+
376+ return process
377+ .run({
378+ operation: "source-control.discovery.auth",
379+ command: input.executable,
380+ args: input.authArgs,
381+ cwd: config.cwd,
382+ allowNonZeroExit: true,
383+ timeoutMs: 5_000,
384+ maxOutputBytes: 8_000,
385+ truncateOutputAtMaxBytes: true,
386+ })
387+ .pipe(
388+ Effect.map(
389+ (result) =>
390+ ({
391+ ...item,
392+ auth: input.parseAuth(result),
393+ }) satisfies SourceControlProviderDiscoveryItem,
394+ ),
395+ Effect.catch((cause) =>
396+ Effect.succeed({
397+ ...item,
398+ auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
399+ } satisfies SourceControlProviderDiscoveryItem),
400+ ),
401+ );
402+ }),
403+ );
404+
163405 return SourceControlDiscovery.of({
164406 discover: Effect.all({
165407 versionControlSystems: Effect.all(
@@ -167,7 +409,7 @@ export const layer = Layer.effect( · 1 unmodified line
167409 { concurrency: "unbounded" },
168410 ),
169411 sourceControlProviders: Effect.all(
170− SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probe(entry)) as ReadonlyArray<
412+ SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray<
171413 Effect.Effect<SourceControlProviderDiscoveryItem>
172414 >,
173415 { concurrency: "unbounded" },
apps/web/src/components/settings/ProviderInstanceCard.tsx+8−44
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
4−import { useEffect, useMemo, useState, type ReactNode } from "react";
4+import { useEffect, useState, type ReactNode } from "react";
55 import {
66 isProviderDriverKind,
77 type ProviderInstanceConfig,
@@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta"; · 16 unmodified lines
2424 import { ProviderSettingsForm } from "./ProviderSettingsForm";
2525 import { ProviderModelsSection } from "./ProviderModelsSection";
2626 import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
27+import { RedactedSensitiveText } from "./RedactedSensitiveText";
2728 import {
2829 PROVIDER_STATUS_STYLES,
2930 getProviderSummary,
@@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [ · 12 unmodified lines
4243
4344 const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
4445
45−const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
46−
4746 let environmentVariableDraftId = 0;
4847 const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
4948
@@ -68,25 +67,6 @@ function makeEnvironmentDraftRow( · 18 unmodified lines
6867 };
6968 }
7069
71−function redactedEmailPlaceholder(email: string): string {
72− let state = 0x811c9dc5;
73− for (let index = 0; index < email.length; index += 1) {
74− state ^= email.charCodeAt(index);
75− state = Math.imul(state, 0x01000193);
76− }
77−
78− const nextChar = () => {
79− state = Math.imul(state ^ (state >>> 13), 0x85ebca6b);
80− state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35);
81− return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x";
82− };
83−
84− return Array.from(email, (char) => {
85− if (char === "@" || char === "." || char === "-" || char === "_") return char;
86− return nextChar();
87− }).join("");
88−}
89−
9070 /**
9171 * Read a string[] at `key` from the opaque config blob, filtering out
9272 * non-string entries. Used for `customModels`, which is always typed as
@@ -146,35 +126,19 @@ function ProviderAuthEmail(props: { · 53 unmodified lines
146126 readonly prefix?: string;
147127 readonly separator?: boolean;
148128 }) {
149− const [revealed, setRevealed] = useState(false);
150129 const trimmed = props.email?.trim();
151− const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]);
152130 if (!trimmed) return null;
153131
154132 return (
155133 <span className="inline-flex min-w-0 items-center gap-1.5">
156134 {props.separator ? <span aria-hidden>·</span> : null}
157135 {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null}
158− <Tooltip>
159− <TooltipTrigger
160− render={
161− <button
162− type="button"
163− className={cn(
164− "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground",
165− revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]",
166− )}
167− onClick={() => setRevealed((value) => !value)}
168− aria-label={revealed ? "Hide account email" : "Reveal account email"}
169− >
170− {revealed ? trimmed : redacted}
171− </button>
172− }
173− />
174− <TooltipPopup side="top">
175− {revealed ? "Click to hide email" : "Click to reveal email"}
176− </TooltipPopup>
177− </Tooltip>
136+ <RedactedSensitiveText
137+ value={trimmed}
138+ ariaLabel="Toggle account email visibility"
139+ revealTooltip="Click to reveal email"
140+ hideTooltip="Click to hide email"
141+ />
178142 </span>
179143 );
180144 }
apps/web/src/components/settings/RedactedSensitiveText.tsx+61−0
@@ -0,0 +1,61 @@
1+import { useMemo, useState } from "react";
2+
3+import { cn } from "../../lib/utils";
4+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5+
6+const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
7+
8+function redactedPlaceholder(value: string): string {
9+ let state = 0x811c9dc5;
10+ for (let index = 0; index < value.length; index += 1) {
11+ state ^= value.charCodeAt(index);
12+ state = Math.imul(state, 0x01000193);
13+ }
14+
15+ const nextChar = () => {
16+ state = Math.imul(state ^ (state >>> 13), 0x85ebca6b);
17+ state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35);
18+ return REDACTED_TEXT_ALPHABET[Math.abs(state) % REDACTED_TEXT_ALPHABET.length] ?? "x";
19+ };
20+
21+ return Array.from(value, (char) => {
22+ if (char === "@" || char === "." || char === "-" || char === "_") return char;
23+ return nextChar();
24+ }).join("");
25+}
26+
27+export function RedactedSensitiveText(props: {
28+ readonly value: string | null | undefined;
29+ readonly ariaLabel: string;
30+ readonly revealTooltip: string;
31+ readonly hideTooltip: string;
32+ readonly className?: string;
33+}) {
34+ const [revealed, setRevealed] = useState(false);
35+ const value = props.value?.trim();
36+ const redacted = useMemo(() => (value ? redactedPlaceholder(value) : ""), [value]);
37+
38+ if (!value) return null;
39+
40+ return (
41+ <Tooltip>
42+ <TooltipTrigger
43+ render={
44+ <button
45+ type="button"
46+ className={cn(
47+ "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground",
48+ revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]",
49+ props.className,
50+ )}
51+ onClick={() => setRevealed((current) => !current)}
52+ aria-label={props.ariaLabel}
53+ >
54+ {revealed ? value : redacted}
55+ </button>
56+ }
57+ />
58+ <TooltipPopup side="top">{revealed ? props.hideTooltip : props.revealTooltip}</TooltipPopup>
59+ </Tooltip>
60+ );
61+}
apps/web/src/components/settings/SourceControlSettings.tsx+91−55
@@ -1,30 +1,26 @@
11 import { ChevronDownIcon, GitBranchIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
22 import { Option } from "effect";
3−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3+import { useMemo, useState } from "react";
44 import type {
55 SourceControlDiscoveryItem,
66 SourceControlDiscoveryResult,
7+ SourceControlProviderAuth,
78 SourceControlProviderDiscoveryItem,
89 VcsDiscoveryItem,
910 } from "@t3tools/contracts";
1011
11−import { ensureLocalApi } from "../../localApi";
1212 import { cn } from "../../lib/utils";
13+import {
14+ refreshSourceControlDiscovery,
15+ useSourceControlDiscovery,
16+} from "../../lib/sourceControlDiscoveryState";
1317 import { Badge } from "../ui/badge";
1418 import { Button } from "../ui/button";
1519 import { Collapsible, CollapsibleContent } from "../ui/collapsible";
1620 import { Switch } from "../ui/switch";
21+import { RedactedSensitiveText } from "./RedactedSensitiveText";
1722 import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
1823
19−type DiscoveryLoadState =
20− | { readonly status: "loading"; readonly result: SourceControlDiscoveryResult | null }
21− | { readonly status: "ready"; readonly result: SourceControlDiscoveryResult }
22− | {
23− readonly status: "error";
24− readonly result: SourceControlDiscoveryResult | null;
25− readonly message: string;
26− };
27−
2824 const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
2925 versionControlSystems: [],
3026 sourceControlProviders: [],
@@ -34,6 +30,46 @@ function optionLabel(value: Option.Option<string>): string | null { · 3 unmodified lines
3430 return Option.getOrNull(value);
3531 }
3632
33+function isProviderDiscoveryItem(
34+ item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem,
35+): item is SourceControlProviderDiscoveryItem {
36+ return "auth" in item;
37+}
38+
39+function authPresentation(auth: SourceControlProviderAuth): {
40+ readonly label: string;
41+ readonly badge: "success" | "warning" | "outline";
42+} {
43+ if (auth.status === "authenticated") {
44+ return { label: "Signed in", badge: "success" };
45+ }
46+ if (auth.status === "unauthenticated") {
47+ return { label: "Sign in", badge: "warning" };
48+ }
49+ return { label: "Auth unknown", badge: "outline" };
50+}
51+
52+function authSummary(auth: SourceControlProviderAuth): string {
53+ if (auth.status === "authenticated") {
54+ return "Authenticated";
55+ }
56+ if (auth.status === "unauthenticated") {
57+ return "Not authenticated";
58+ }
59+ return "Auth not checked";
60+}
61+
62+function RedactedAccount(props: { readonly account: string | null }) {
63+ return (
64+ <RedactedSensitiveText
65+ value={props.account}
66+ ariaLabel="Toggle source control account visibility"
67+ revealTooltip="Click to reveal account"
68+ hideTooltip="Click to hide account"
69+ />
70+ );
71+}
72+
3773 function statusPresentation(item: SourceControlDiscoveryItem): {
3874 readonly label: string;
3975 readonly badge: "success" | "warning" | "outline";
@@ -80,6 +116,11 @@ function DiscoveryItemRow({ · 40 unmodified lines
80116 const version = optionLabel(item.version);
81117 const detail = optionLabel(item.detail);
82118 const enabled = item.implemented && item.status === "available";
119+ const auth = isProviderDiscoveryItem(item) ? item.auth : null;
120+ const authStatus = auth ? authPresentation(auth) : null;
121+ const authAccount = auth ? optionLabel(auth.account) : null;
122+ const authHost = auth ? optionLabel(auth.host) : null;
123+ const authDetail = auth ? optionLabel(auth.detail) : null;
83124
84125 return (
85126 <div
@@ -104,6 +145,11 @@ function DiscoveryItemRow({ · 18 unmodified lines
104145 Not in this branch
105146 </Badge>
106147 ) : null}
148+ {authStatus ? (
149+ <Badge variant={authStatus.badge} size="sm">
150+ {authStatus.label}
151+ </Badge>
152+ ) : null}
107153 </div>
108154 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
109155 <span>CLI</span>
@@ -111,6 +157,13 @@ function DiscoveryItemRow({ · 1 unmodified line
111157 {item.executable}
112158 </code>
113159 {version ? <span>- {version}</span> : null}
160+ {auth ? (
161+ <>
162+ <span aria-hidden>-</span>
163+ <span>{authSummary(auth)}</span>
164+ {authAccount ? <RedactedAccount account={authAccount} /> : null}
165+ </>
166+ ) : null}
114167 </p>
115168 </div>
116169 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
@@ -138,6 +191,23 @@ function DiscoveryItemRow({ · 21 unmodified lines
138191 <dd className="min-w-0 font-mono text-foreground">{item.executable}</dd>
139192 <dt className="text-muted-foreground">Version</dt>
140193 <dd className="min-w-0 text-foreground">{version ?? "Not detected"}</dd>
194+ {auth ? (
195+ <>
196+ <dt className="text-muted-foreground">Auth</dt>
197+ <dd className="min-w-0 space-y-1 text-foreground">
198+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
199+ <span>{authSummary(auth)}</span>
200+ {authAccount ? <RedactedAccount account={authAccount} /> : null}
201+ {authHost ? (
202+ <span className="text-muted-foreground">on {authHost}</span>
203+ ) : null}
204+ </div>
205+ {authDetail ? (
206+ <div className="break-words text-muted-foreground">{authDetail}</div>
207+ ) : null}
208+ </dd>
209+ </>
210+ ) : null}
141211 <dt className="text-muted-foreground">Install</dt>
142212 <dd className="min-w-0 leading-relaxed text-foreground">{item.installHint}</dd>
143213 <dt className="text-muted-foreground">Build</dt>
@@ -161,49 +231,15 @@ function DiscoveryItemRow({ · 17 unmodified lines
161231 }
162232
163233 export function SourceControlSettingsPanel() {
164− const [loadState, setLoadState] = useState<DiscoveryLoadState>({
165− status: "loading",
166− result: null,
167− });
168− const latestResultRef = useRef<SourceControlDiscoveryResult | null>(null);
234+ const discovery = useSourceControlDiscovery();
169235 const [expanded, setExpanded] = useState<Readonly<Record<string, boolean>>>({});
170236
171− const refresh = useCallback((options?: { readonly signal?: AbortSignal }) => {
172− const previous = latestResultRef.current;
173− setLoadState({ status: "loading", result: previous });
174−
175− ensureLocalApi()
176− .server.discoverSourceControl()
177− .then((result) => {
178− if (!options?.signal?.aborted) {
179− latestResultRef.current = result;
180− setLoadState({ status: "ready", result });
181− }
182− })
183− .catch((cause: unknown) => {
184− if (!options?.signal?.aborted) {
185− setLoadState({
186− status: "error",
187− result: previous,
188− message:
189− cause instanceof Error ? cause.message : "Failed to discover source control tools.",
190− });
191− }
192− });
193− }, []);
194−
195− useEffect(() => {
196− const controller = new AbortController();
197− refresh({ signal: controller.signal });
198− return () => controller.abort();
199− }, [refresh]);
200−
201− const result = loadState.result ?? EMPTY_DISCOVERY_RESULT;
237+ const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;
202238 const statusText = useMemo(() => {
203− if (loadState.status === "loading") return "Scanning installed tools...";
204− if (loadState.status === "error") return loadState.message;
239+ if (discovery.isPending) return "Scanning installed tools...";
240+ if (discovery.error) return discovery.error;
205241 return "Detected source control tools on this system.";
206− }, [loadState]);
242+ }, [discovery.error, discovery.isPending]);
207243
208244 const setItemExpanded = (key: string, value: boolean) => {
209245 setExpanded((current) => ({ ...current, [key]: value }));
@@ -224,12 +260,12 @@ export function SourceControlSettingsPanel() { · 14 unmodified lines
224260 size="sm"
225261 variant="ghost"
226262 className="h-7 gap-1.5 px-2 text-xs"
227− onClick={() => refresh()}
228− disabled={loadState.status === "loading"}
263+ onClick={() => {
264+ void refreshSourceControlDiscovery();
265+ }}
266+ disabled={discovery.isPending}
229267 >
230− <RefreshCwIcon
231− className={cn("size-3.5", loadState.status === "loading" && "animate-spin")}
232− />
268+ <RefreshCwIcon className={cn("size-3.5", discovery.isPending && "animate-spin")} />
233269 Scan
234270 </Button>
235271 }
apps/web/src/lib/sourceControlDiscoveryState.ts+44−0
@@ -0,0 +1,44 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
3+ EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
4+ EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
5+ type SourceControlDiscoveryState,
6+ createSourceControlDiscoveryManager,
7+ getSourceControlDiscoveryTargetKey,
8+ sourceControlDiscoveryStateAtom,
9+} from "@t3tools/client-runtime";
10+import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
11+import { useEffect } from "react";
12+
13+import { readLocalApi } from "../localApi";
14+import { appAtomRegistry } from "../rpc/atomRegistry";
15+
16+const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const;
17+
18+export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
19+ getRegistry: () => appAtomRegistry,
20+ getClient: () => readLocalApi()?.server ?? null,
21+});
22+
23+export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> {
24+ return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET);
25+}
26+
27+export function resetSourceControlDiscoveryStateForTests(): void {
28+ sourceControlDiscoveryManager.reset();
29+}
30+
31+export function useSourceControlDiscovery(): SourceControlDiscoveryState {
32+ const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET);
33+
34+ useEffect(() => {
35+ void sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET);
36+ }, []);
37+
38+ const state = useAtomValue(
39+ targetKey !== null
40+ ? sourceControlDiscoveryStateAtom(targetKey)
41+ : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
42+ );
43+ return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state;
44+}
apps/web/src/localApi.ts+2−0
@@ -1,6 +1,7 @@
11 import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
22
33 import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4+import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState";
45 import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
56 import { resetServerStateForTests } from "./rpc/serverState";
67 import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
@@ -147,6 +148,7 @@ export async function __resetLocalApiForTests() { · 140 unmodified lines
147148 __resetClientSettingsPersistenceForTests();
148149 await resetEnvironmentServiceForTests();
149150 resetGitStatusStateForTests();
151+ resetSourceControlDiscoveryStateForTests();
150152 resetRequestLatencyStateForTests();
151153 resetSavedEnvironmentRegistryStoreForTests();
152154 resetSavedEnvironmentRuntimeStoreForTests();
bun.lock+1−0
@@ -131,6 +131,7 @@ · 130 unmodified lines
131131 "version": "0.0.0-alpha.1",
132132 "dependencies": {
133133 "@t3tools/contracts": "workspace:*",
134+ "effect": "catalog:",
134135 },
135136 "devDependencies": {
136137 "@effect/language-service": "catalog:",
packages/client-runtime/package.json+2−1
@@ -15,7 +15,8 @@ · 14 unmodified lines
1515 "test": "vitest run"
1616 },
1717 "dependencies": {
18− "@t3tools/contracts": "workspace:*"
18+ "@t3tools/contracts": "workspace:*",
19+ "effect": "catalog:"
1920 },
2021 "devDependencies": {
2122 "@effect/language-service": "catalog:",
packages/client-runtime/src/index.ts+1−0
@@ -1,2 +1,3 @@
11 export * from "./knownEnvironment.ts";
22 export * from "./scoped.ts";
3+export * from "./sourceControlDiscoveryState.ts";
packages/client-runtime/src/sourceControlDiscoveryState.test.ts+107−0
@@ -0,0 +1,107 @@
1+import { assert, beforeEach, it } from "vitest";
2+import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
3+import { AtomRegistry } from "effect/unstable/reactivity";
4+
5+import {
6+ EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
7+ createSourceControlDiscoveryManager,
8+} from "./sourceControlDiscoveryState.ts";
9+
10+const EMPTY_RESULT: SourceControlDiscoveryResult = {
11+ versionControlSystems: [],
12+ sourceControlProviders: [],
13+};
14+
15+function unresolvedDiscovery() {
16+ throw new Error("Discovery resolver was not initialized.");
17+}
18+
19+let registry = AtomRegistry.make();
20+
21+beforeEach(() => {
22+ registry.dispose();
23+ registry = AtomRegistry.make();
24+});
25+
26+it("stores refreshed discovery data in an atom snapshot", async () => {
27+ const manager = createSourceControlDiscoveryManager({
28+ getRegistry: () => registry,
29+ getClient: () => ({
30+ discoverSourceControl: async () => EMPTY_RESULT,
31+ }),
32+ });
33+
34+ assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE);
35+
36+ const result = await manager.refresh({ key: "primary" });
37+
38+ assert.strictEqual(result, EMPTY_RESULT);
39+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
40+ data: EMPTY_RESULT,
41+ error: null,
42+ isPending: false,
43+ });
44+});
45+
46+it("deduplicates in-flight discovery refreshes by target key", async () => {
47+ let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery;
48+ let calls = 0;
49+ const manager = createSourceControlDiscoveryManager({
50+ getRegistry: () => registry,
51+ getClient: () => ({
52+ discoverSourceControl: () => {
53+ calls += 1;
54+ return new Promise<SourceControlDiscoveryResult>((resolve) => {
55+ resolveDiscovery = resolve;
56+ });
57+ },
58+ }),
59+ });
60+
61+ const first = manager.refresh({ key: "primary" });
62+ const second = manager.refresh({ key: "primary" });
63+
64+ assert.strictEqual(first, second);
65+ assert.strictEqual(calls, 1);
66+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
67+ data: null,
68+ error: null,
69+ isPending: true,
70+ });
71+
72+ resolveDiscovery(EMPTY_RESULT);
73+ await first;
74+
75+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
76+ data: EMPTY_RESULT,
77+ error: null,
78+ isPending: false,
79+ });
80+});
81+
82+it("keeps the previous snapshot when refresh fails", async () => {
83+ let shouldFail = false;
84+ const manager = createSourceControlDiscoveryManager({
85+ getRegistry: () => registry,
86+ getClient: () => ({
87+ discoverSourceControl: async () => {
88+ if (shouldFail) {
89+ throw new Error("probe failed");
90+ }
91+ return EMPTY_RESULT;
92+ },
93+ }),
94+ });
95+
96+ await manager.refresh({ key: "primary" });
97+ shouldFail = true;
98+
99+ const result = await manager.refresh({ key: "primary" });
100+
101+ assert.strictEqual(result, EMPTY_RESULT);
102+ assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
103+ data: EMPTY_RESULT,
104+ error: "probe failed",
105+ isPending: false,
106+ });
107+});
packages/client-runtime/src/sourceControlDiscoveryState.ts+206−0
@@ -0,0 +1,206 @@
1+import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
2+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3+
4+/* --- Types ---------------------------------------------------------- */
5+
6+export interface SourceControlDiscoveryState {
7+ readonly data: SourceControlDiscoveryResult | null;
8+ readonly error: string | null;
9+ readonly isPending: boolean;
10+}
11+
12+export interface SourceControlDiscoveryTarget {
13+ readonly key: string | null;
14+}
15+
16+export interface SourceControlDiscoveryClient {
17+ readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
18+}
19+
20+/* --- Constants ------------------------------------------------------ */
21+
22+export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
23+ data: null,
24+ error: null,
25+ isPending: false,
26+});
27+
28+const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
29+ data: null,
30+ error: null,
31+ isPending: true,
32+});
33+
34+/* --- Atoms ---------------------------------------------------------- */
35+
36+const knownSourceControlDiscoveryKeys = new Set<string>();
37+
38+export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => {
39+ knownSourceControlDiscoveryKeys.add(key);
40+ return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe(
41+ Atom.keepAlive,
42+ Atom.withLabel(`source-control-discovery:${key}`),
43+ );
44+});
45+
46+export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make(
47+ EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
48+).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null"));
49+
50+/* --- Helpers -------------------------------------------------------- */
51+
52+export function getSourceControlDiscoveryTargetKey(
53+ target: SourceControlDiscoveryTarget,
54+): string | null {
55+ const key = target.key?.trim();
56+ return key && key.length > 0 ? key : null;
57+}
58+
59+/* --- Refresh manager ------------------------------------------------ */
60+
61+export interface SourceControlDiscoveryManagerConfig {
62+ /**
63+ * Get the atom registry used to read/write source-control discovery snapshots.
64+ */
65+ readonly getRegistry: () => AtomRegistry.AtomRegistry;
66+ /**
67+ * Resolve the runtime client for a discovery target key.
68+ *
69+ * Web currently uses a single `"primary"` target, but keeping this keyed
70+ * lets mobile or future multi-environment clients provide separate discovery
71+ * clients without changing the state primitive.
72+ */
73+ readonly getClient: (key: string) => SourceControlDiscoveryClient | null;
74+}
75+
76+export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) {
77+ const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>();
78+
79+ /* -- Atom helpers -------------------------------------------------- */
80+
81+ function setState(targetKey: string, nextState: SourceControlDiscoveryState): void {
82+ config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState);
83+ }
84+
85+ function markPending(targetKey: string): void {
86+ const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
87+ const next: SourceControlDiscoveryState =
88+ current.data === null
89+ ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE
90+ : {
91+ data: current.data,
92+ error: null,
93+ isPending: true,
94+ };
95+
96+ if (
97+ current.data === next.data &&
98+ current.error === next.error &&
99+ current.isPending === next.isPending
100+ ) {
101+ return;
102+ }
103+
104+ setState(targetKey, next);
105+ }
106+
107+ function setData(targetKey: string, data: SourceControlDiscoveryResult): void {
108+ setState(targetKey, {
109+ data,
110+ error: null,
111+ isPending: false,
112+ });
113+ }
114+
115+ function setError(targetKey: string, error: unknown): void {
116+ const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
117+ setState(targetKey, {
118+ data: current.data,
119+ error: error instanceof Error ? error.message : "Failed to discover source control tools.",
120+ isPending: false,
121+ });
122+ }
123+
124+ /* -- Public API ---------------------------------------------------- */
125+
126+ /**
127+ * Trigger a one-shot source-control discovery RPC for a target.
128+ *
129+ * Calls are deduplicated while a refresh for the same target key is in
130+ * flight. On failure, the previous successful snapshot is kept in `data`
131+ * and the error message is stored separately so UI can keep rendering stale
132+ * discovery results while showing the failure.
133+ *
134+ * @param target The logical runtime target to refresh.
135+ * @param client Optional pre-resolved client, useful in tests.
136+ */
137+ function refresh(
138+ target: SourceControlDiscoveryTarget,
139+ client?: SourceControlDiscoveryClient,
140+ ): Promise<SourceControlDiscoveryResult | null> {
141+ const targetKey = getSourceControlDiscoveryTargetKey(target);
142+ if (targetKey === null) {
143+ return Promise.resolve(null);
144+ }
145+
146+ const existing = refreshInFlight.get(targetKey);
147+ if (existing) {
148+ return existing;
149+ }
150+
151+ const resolvedClient = client ?? config.getClient(targetKey);
152+ if (!resolvedClient) {
153+ const error = new Error("Source control discovery client is unavailable.");
154+ setError(targetKey, error);
155+ return Promise.resolve(getSnapshot(target).data);
156+ }
157+
158+ markPending(targetKey);
159+ const promise = resolvedClient.discoverSourceControl().then(
160+ (result) => {
161+ setData(targetKey, result);
162+ return result;
163+ },
164+ (error: unknown) => {
165+ setError(targetKey, error);
166+ return getSnapshot(target).data;
167+ },
168+ );
169+ const tracked = promise.finally(() => refreshInFlight.delete(targetKey));
170+ refreshInFlight.set(targetKey, tracked);
171+ return tracked;
172+ }
173+
174+ /**
175+ * Read the current atom snapshot for `target`.
176+ *
177+ * Invalid targets return the inert empty state rather than creating a new
178+ * family atom entry.
179+ */
180+ function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState {
181+ const targetKey = getSourceControlDiscoveryTargetKey(target);
182+ if (targetKey === null) {
183+ return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE;
184+ }
185+
186+ return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
187+ }
188+
189+ /**
190+ * Clear in-flight refresh tracking and reset every known discovery atom.
191+ * Primarily used by tests and runtime teardown.
192+ */
193+ function reset(): void {
194+ refreshInFlight.clear();
195+ for (const key of knownSourceControlDiscoveryKeys) {
196+ setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE);
197+ }
198+ knownSourceControlDiscoveryKeys.clear();
199+ }
200+
201+ return {
202+ refresh,
203+ getSnapshot,
204+ reset,
205+ };
206+}
packages/contracts/src/sourceControl.ts+16−0
@@ -46,6 +46,21 @@ export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryClo · 45 unmodified lines
4646 export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]);
4747 export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type;
4848
49+export const SourceControlProviderAuthStatus = Schema.Literals([
50+ "authenticated",
51+ "unauthenticated",
52+ "unknown",
53+]);
54+export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type;
55+
56+export const SourceControlProviderAuth = Schema.Struct({
57+ status: SourceControlProviderAuthStatus,
58+ account: Schema.Option(TrimmedNonEmptyString),
59+ host: Schema.Option(TrimmedNonEmptyString),
60+ detail: Schema.Option(TrimmedNonEmptyString),
61+});
62+export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
63+
4964 const SourceControlDiscoveryItemFields = {
5065 label: TrimmedNonEmptyString,
5166 executable: TrimmedNonEmptyString,
@@ -71,6 +86,7 @@ export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; · 19 unmodified lines
7186 export const SourceControlProviderDiscoveryItem = Schema.Struct({
7287 kind: SourceControlProviderKind,
7388 ...SourceControlDiscoveryItemFields,
89+ auth: SourceControlProviderAuth,
7490 });
7591 export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type;
7692