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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Effect, Layer } from "effect";3import { Effect, Layer, Option } from "effect";
4import { ChildProcessSpawner } from "effect/unstable/process";4import { ChildProcessSpawner } from "effect/unstable/process";
5import { VcsProcessSpawnError } from "@t3tools/contracts";5import { VcsProcessSpawnError } from "@t3tools/contracts";
6 6
@@ -8,10 +8,16 @@ import { ServerConfig } from "../config.ts"; · 1 unmodified line
8import * as VcsProcess from "../vcs/VcsProcess.ts";8import * as VcsProcess from "../vcs/VcsProcess.ts";
9import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";9import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";
10 10
11const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({11const processOutput = (
12 exitCode: ChildProcessSpawner.ExitCode(0),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),
13 stdout,19 stdout,
14 stderr: "",20 stderr: options?.stderr ?? "",
15 stdoutTruncated: false,21 stdoutTruncated: false,
16 stderrTruncated: false,22 stderrTruncated: false,
17});23});
@@ -27,9 +33,20 @@ it.effect("reports implemented tools separately from locally available CLIs", () · 9 unmodified lines
27 if (input.command === "git") {33 if (input.command === "git") {
28 return Effect.succeed(processOutput("git version 2.51.0\n"));34 return Effect.succeed(processOutput("git version 2.51.0\n"));
29 }35 }
30 if (input.command === "gh") {36 if (input.command === "gh" && input.args[0] === "--version") {
31 return Effect.succeed(processOutput("gh version 2.83.0\n"));37 return Effect.succeed(processOutput("gh version 2.83.0\n"));
32 }38 }
39 if (input.command === "gh" && input.args.join(" ") === "auth status") {
40 return Effect.succeed(
41 processOutput(`github.com
42Logged 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 }
33 return Effect.fail(50 return Effect.fail(
34 new VcsProcessSpawnError({51 new VcsProcessSpawnError({
35 operation: input.operation,52 operation: input.operation,
@@ -65,12 +82,133 @@ it.effect("reports implemented tools separately from locally available CLIs", () · 29 unmodified lines
65 kind: item.kind,82 kind: item.kind,
66 implemented: item.implemented,83 implemented: item.implemented,
67 status: item.status,84 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
122it.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
136Logged 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
145Logged 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
158Logged 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,
68 })),186 })),
69 [187 [
70 { kind: "github", implemented: true, status: "available" },188 {
71 { kind: "gitlab", implemented: false, status: "missing" },189 kind: "github",
72 { kind: "azure-devops", implemented: false, status: "missing" },190 auth: "authenticated",
73 { kind: "bitbucket", implemented: false, status: "missing" },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 },
74 ],212 ],
75 );213 );
76 }).pipe(Effect.provide(testLayer));214 }).pipe(Effect.provide(testLayer));
apps/server/src/sourceControl/SourceControlDiscovery.ts+257−15
@@ -1,4 +1,5 @@
1import {1import {
2 type SourceControlProviderAuth,
2 type SourceControlDiscoveryResult,3 type SourceControlDiscoveryResult,
3 type SourceControlProviderDiscoveryItem,4 type SourceControlProviderDiscoveryItem,
4 type SourceControlProviderKind,5 type SourceControlProviderKind,
@@ -24,8 +25,27 @@ type VcsProbe = DiscoveryProbe & { · 19 unmodified lines
24 25
25type ProviderProbe = DiscoveryProbe & {26type ProviderProbe = DiscoveryProbe & {
26 readonly kind: SourceControlProviderKind;27 readonly kind: SourceControlProviderKind;
28 readonly authArgs: ReadonlyArray<string>;
29 readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
27};30};
28 31
32interface AuthProbeInput {
33 readonly stdout: string;
34 readonly stderr: string;
35 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
36}
37
38interface 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
29const VCS_PROBES: ReadonlyArray<VcsProbe> = [49const VCS_PROBES: ReadonlyArray<VcsProbe> = [
30 {50 {
31 kind: "git",51 kind: "git",
@@ -59,6 +79,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 27 unmodified lines
59 label: "GitHub",79 label: "GitHub",
60 executable: "gh",80 executable: "gh",
61 versionArgs: ["--version"],81 versionArgs: ["--version"],
82 authArgs: ["auth", "status"],
83 parseAuth: parseGitHubAuth,
62 implemented: true,84 implemented: true,
63 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",85 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
64 },86 },
@@ -67,6 +89,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 2 unmodified lines
67 label: "GitLab",89 label: "GitLab",
68 executable: "glab",90 executable: "glab",
69 versionArgs: ["--version"],91 versionArgs: ["--version"],
92 authArgs: ["auth", "status"],
93 parseAuth: parseGitLabAuth,
70 implemented: false,94 implemented: false,
71 installHint:95 installHint:
72 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",96 "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
76 label: "Azure DevOps",100 label: "Azure DevOps",
77 executable: "az",101 executable: "az",
78 versionArgs: ["--version"],102 versionArgs: ["--version"],
103 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
104 parseAuth: parseAzureAuth,
79 implemented: false,105 implemented: false,
80 installHint:106 installHint:
81 "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",107 "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
85 label: "Bitbucket",111 label: "Bitbucket",
86 executable: "bb",112 executable: "bb",
87 versionArgs: ["--version"],113 versionArgs: ["--version"],
114 authArgs: ["auth", "status"],
115 parseAuth: parseBitbucketAuth,
88 implemented: false,116 implemented: false,
89 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",117 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
90 },118 },
@@ -105,6 +133,178 @@ function detailFromCause(cause: unknown): Option.Option<string> { · 14 unmodified lines
105 return Option.none();133 return Option.none();
106}134}
107 135
136function 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
141function 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
146function 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
151function 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
165function unknownAuth(detail?: string): SourceControlProviderAuth {
166 return providerAuth({ status: "unknown", detail });
167}
168
169function combinedAuthOutput(input: AuthProbeInput): string {
170 return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
171}
172
173function 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
181function firstSafeAuthLine(text: string): string | undefined {
182 return sanitizedAuthLines(text)[0];
183}
184
185function 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
191function 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
200function 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
227function 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
255function 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
277function 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
108export interface SourceControlDiscoveryShape {308export interface SourceControlDiscoveryShape {
109 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;309 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
110}310}
@@ -122,7 +322,7 @@ export const layer = Layer.effect( · 11 unmodified lines
122 322
123 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(323 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
124 input: DiscoveryProbe & { readonly kind: Kind },324 input: DiscoveryProbe & { readonly kind: Kind },
125 ) =>325 ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
126 process326 process
127 .run({327 .run({
128 operation: "source-control.discovery.probe",328 operation: "source-control.discovery.probe",
@@ -134,18 +334,21 @@ export const layer = Layer.effect( · 5 unmodified lines
134 truncateOutputAtMaxBytes: true,334 truncateOutputAtMaxBytes: true,
135 })335 })
136 .pipe(336 .pipe(
137 Effect.map((result) => ({337 Effect.map(
138 kind: input.kind,338 (result) =>
139 label: input.label,339 ({
140 executable: input.executable,340 kind: input.kind,
141 implemented: input.implemented,341 label: input.label,
142 status: "available" as const,342 executable: input.executable,
143 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>343 implemented: input.implemented,
144 firstNonEmptyLine(result.stderr),344 status: "available" as const,
145 ),345 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
146 installHint: input.installHint,346 firstNonEmptyLine(result.stderr),
147 detail: Option.none<string>(),347 ),
148 })),348 installHint: input.installHint,
349 detail: Option.none<string>(),
350 }) satisfies DiscoveryProbeResult<Kind>,
351 ),
149 Effect.catch((cause) =>352 Effect.catch((cause) =>
150 Effect.succeed({353 Effect.succeed({
151 kind: input.kind,354 kind: input.kind,
@@ -156,10 +359,49 @@ export const layer = Layer.effect( · 4 unmodified lines
156 version: Option.none<string>(),359 version: Option.none<string>(),
157 installHint: input.installHint,360 installHint: input.installHint,
158 detail: detailFromCause(cause),361 detail: detailFromCause(cause),
159 }),362 } satisfies DiscoveryProbeResult<Kind>),
160 ),363 ),
161 );364 );
162 365
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
163 return SourceControlDiscovery.of({405 return SourceControlDiscovery.of({
164 discover: Effect.all({406 discover: Effect.all({
165 versionControlSystems: Effect.all(407 versionControlSystems: Effect.all(
@@ -167,7 +409,7 @@ export const layer = Layer.effect( · 1 unmodified line
167 { concurrency: "unbounded" },409 { concurrency: "unbounded" },
168 ),410 ),
169 sourceControlProviders: Effect.all(411 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<
171 Effect.Effect<SourceControlProviderDiscoveryItem>413 Effect.Effect<SourceControlProviderDiscoveryItem>
172 >,414 >,
173 { concurrency: "unbounded" },415 { concurrency: "unbounded" },
apps/web/src/components/settings/ProviderInstanceCard.tsx+8−44
@@ -1,7 +1,7 @@
1"use client";1"use client";
2 2
3import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";3import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useState, type ReactNode } from "react";4import { useEffect, useState, type ReactNode } from "react";
5import {5import {
6 isProviderDriverKind,6 isProviderDriverKind,
7 type ProviderInstanceConfig,7 type ProviderInstanceConfig,
@@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta"; · 16 unmodified lines
24import { ProviderSettingsForm } from "./ProviderSettingsForm";24import { ProviderSettingsForm } from "./ProviderSettingsForm";
25import { ProviderModelsSection } from "./ProviderModelsSection";25import { ProviderModelsSection } from "./ProviderModelsSection";
26import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";26import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
27import { RedactedSensitiveText } from "./RedactedSensitiveText";
27import {28import {
28 PROVIDER_STATUS_STYLES,29 PROVIDER_STATUS_STYLES,
29 getProviderSummary,30 getProviderSummary,
@@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [ · 12 unmodified lines
42 43
43const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;44const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
44 45
45const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
46
47let environmentVariableDraftId = 0;46let environmentVariableDraftId = 0;
48const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;47const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
49 48
@@ -68,25 +67,6 @@ function makeEnvironmentDraftRow( · 18 unmodified lines
68 };67 };
69}68}
70 69
71function 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
90/**70/**
91 * Read a string[] at `key` from the opaque config blob, filtering out71 * Read a string[] at `key` from the opaque config blob, filtering out
92 * non-string entries. Used for `customModels`, which is always typed as72 * non-string entries. Used for `customModels`, which is always typed as
@@ -146,35 +126,19 @@ function ProviderAuthEmail(props: { · 53 unmodified lines
146 readonly prefix?: string;126 readonly prefix?: string;
147 readonly separator?: boolean;127 readonly separator?: boolean;
148}) {128}) {
149 const [revealed, setRevealed] = useState(false);
150 const trimmed = props.email?.trim();129 const trimmed = props.email?.trim();
151 const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]);
152 if (!trimmed) return null;130 if (!trimmed) return null;
153 131
154 return (132 return (
155 <span className="inline-flex min-w-0 items-center gap-1.5">133 <span className="inline-flex min-w-0 items-center gap-1.5">
156 {props.separator ? <span aria-hidden>·</span> : null}134 {props.separator ? <span aria-hidden>·</span> : null}
157 {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null}135 {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null}
158 <Tooltip>136 <RedactedSensitiveText
159 <TooltipTrigger137 value={trimmed}
160 render={138 ariaLabel="Toggle account email visibility"
161 <button139 revealTooltip="Click to reveal email"
162 type="button"140 hideTooltip="Click to hide email"
163 className={cn(141 />
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>
178 </span>142 </span>
179 );143 );
180}144}
apps/web/src/components/settings/RedactedSensitiveText.tsx+61−0
@@ -0,0 +1,61 @@
1import { useMemo, useState } from "react";
2
3import { cn } from "../../lib/utils";
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5
6const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
7
8function 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
27export 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 @@
1import { ChevronDownIcon, GitBranchIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";1import { ChevronDownIcon, GitBranchIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
2import { Option } from "effect";2import { Option } from "effect";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useMemo, useState } from "react";
4import type {4import type {
5 SourceControlDiscoveryItem,5 SourceControlDiscoveryItem,
6 SourceControlDiscoveryResult,6 SourceControlDiscoveryResult,
7 SourceControlProviderAuth,
7 SourceControlProviderDiscoveryItem,8 SourceControlProviderDiscoveryItem,
8 VcsDiscoveryItem,9 VcsDiscoveryItem,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10 11
11import { ensureLocalApi } from "../../localApi";
12import { cn } from "../../lib/utils";12import { cn } from "../../lib/utils";
13import {
14 refreshSourceControlDiscovery,
15 useSourceControlDiscovery,
16} from "../../lib/sourceControlDiscoveryState";
13import { Badge } from "../ui/badge";17import { Badge } from "../ui/badge";
14import { Button } from "../ui/button";18import { Button } from "../ui/button";
15import { Collapsible, CollapsibleContent } from "../ui/collapsible";19import { Collapsible, CollapsibleContent } from "../ui/collapsible";
16import { Switch } from "../ui/switch";20import { Switch } from "../ui/switch";
21import { RedactedSensitiveText } from "./RedactedSensitiveText";
17import { SettingsPageContainer, SettingsSection } from "./settingsLayout";22import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
18 23
19type 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
28const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {24const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
29 versionControlSystems: [],25 versionControlSystems: [],
30 sourceControlProviders: [],26 sourceControlProviders: [],
@@ -34,6 +30,46 @@ function optionLabel(value: Option.Option<string>): string | null { · 3 unmodified lines
34 return Option.getOrNull(value);30 return Option.getOrNull(value);
35}31}
36 32
33function isProviderDiscoveryItem(
34 item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem,
35): item is SourceControlProviderDiscoveryItem {
36 return "auth" in item;
37}
38
39function 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
52function 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
62function 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
37function statusPresentation(item: SourceControlDiscoveryItem): {73function statusPresentation(item: SourceControlDiscoveryItem): {
38 readonly label: string;74 readonly label: string;
39 readonly badge: "success" | "warning" | "outline";75 readonly badge: "success" | "warning" | "outline";
@@ -80,6 +116,11 @@ function DiscoveryItemRow({ · 40 unmodified lines
80 const version = optionLabel(item.version);116 const version = optionLabel(item.version);
81 const detail = optionLabel(item.detail);117 const detail = optionLabel(item.detail);
82 const enabled = item.implemented && item.status === "available";118 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;
83 124
84 return (125 return (
85 <div126 <div
@@ -104,6 +145,11 @@ function DiscoveryItemRow({ · 18 unmodified lines
104 Not in this branch145 Not in this branch
105 </Badge>146 </Badge>
106 ) : null}147 ) : null}
148 {authStatus ? (
149 <Badge variant={authStatus.badge} size="sm">
150 {authStatus.label}
151 </Badge>
152 ) : null}
107 </div>153 </div>
108 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">154 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
109 <span>CLI</span>155 <span>CLI</span>
@@ -111,6 +157,13 @@ function DiscoveryItemRow({ · 1 unmodified line
111 {item.executable}157 {item.executable}
112 </code>158 </code>
113 {version ? <span>- {version}</span> : null}159 {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}
114 </p>167 </p>
115 </div>168 </div>
116 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">169 <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
138 <dd className="min-w-0 font-mono text-foreground">{item.executable}</dd>191 <dd className="min-w-0 font-mono text-foreground">{item.executable}</dd>
139 <dt className="text-muted-foreground">Version</dt>192 <dt className="text-muted-foreground">Version</dt>
140 <dd className="min-w-0 text-foreground">{version ?? "Not detected"}</dd>193 <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}
141 <dt className="text-muted-foreground">Install</dt>211 <dt className="text-muted-foreground">Install</dt>
142 <dd className="min-w-0 leading-relaxed text-foreground">{item.installHint}</dd>212 <dd className="min-w-0 leading-relaxed text-foreground">{item.installHint}</dd>
143 <dt className="text-muted-foreground">Build</dt>213 <dt className="text-muted-foreground">Build</dt>
@@ -161,49 +231,15 @@ function DiscoveryItemRow({ · 17 unmodified lines
161}231}
162 232
163export function SourceControlSettingsPanel() {233export function SourceControlSettingsPanel() {
164 const [loadState, setLoadState] = useState<DiscoveryLoadState>({234 const discovery = useSourceControlDiscovery();
165 status: "loading",
166 result: null,
167 });
168 const latestResultRef = useRef<SourceControlDiscoveryResult | null>(null);
169 const [expanded, setExpanded] = useState<Readonly<Record<string, boolean>>>({});235 const [expanded, setExpanded] = useState<Readonly<Record<string, boolean>>>({});
170 236
171 const refresh = useCallback((options?: { readonly signal?: AbortSignal }) => {237 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;
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;
202 const statusText = useMemo(() => {238 const statusText = useMemo(() => {
203 if (loadState.status === "loading") return "Scanning installed tools...";239 if (discovery.isPending) return "Scanning installed tools...";
204 if (loadState.status === "error") return loadState.message;240 if (discovery.error) return discovery.error;
205 return "Detected source control tools on this system.";241 return "Detected source control tools on this system.";
206 }, [loadState]);242 }, [discovery.error, discovery.isPending]);
207 243
208 const setItemExpanded = (key: string, value: boolean) => {244 const setItemExpanded = (key: string, value: boolean) => {
209 setExpanded((current) => ({ ...current, [key]: value }));245 setExpanded((current) => ({ ...current, [key]: value }));
@@ -224,12 +260,12 @@ export function SourceControlSettingsPanel() { · 14 unmodified lines
224 size="sm"260 size="sm"
225 variant="ghost"261 variant="ghost"
226 className="h-7 gap-1.5 px-2 text-xs"262 className="h-7 gap-1.5 px-2 text-xs"
227 onClick={() => refresh()}263 onClick={() => {
228 disabled={loadState.status === "loading"}264 void refreshSourceControlDiscovery();
265 }}
266 disabled={discovery.isPending}
229 >267 >
230 <RefreshCwIcon268 <RefreshCwIcon className={cn("size-3.5", discovery.isPending && "animate-spin")} />
231 className={cn("size-3.5", loadState.status === "loading" && "animate-spin")}
232 />
233 Scan269 Scan
234 </Button>270 </Button>
235 }271 }
apps/web/src/lib/sourceControlDiscoveryState.ts+44−0
@@ -0,0 +1,44 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
10import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
11import { useEffect } from "react";
12
13import { readLocalApi } from "../localApi";
14import { appAtomRegistry } from "../rpc/atomRegistry";
15
16const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const;
17
18export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
19 getRegistry: () => appAtomRegistry,
20 getClient: () => readLocalApi()?.server ?? null,
21});
22
23export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> {
24 return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET);
25}
26
27export function resetSourceControlDiscoveryStateForTests(): void {
28 sourceControlDiscoveryManager.reset();
29}
30
31export 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 @@
1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
2 2
3import { resetGitStatusStateForTests } from "./lib/gitStatusState";3import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState";
4import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";5import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
5import { resetServerStateForTests } from "./rpc/serverState";6import { resetServerStateForTests } from "./rpc/serverState";
6import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";7import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
@@ -147,6 +148,7 @@ export async function __resetLocalApiForTests() { · 140 unmodified lines
147 __resetClientSettingsPersistenceForTests();148 __resetClientSettingsPersistenceForTests();
148 await resetEnvironmentServiceForTests();149 await resetEnvironmentServiceForTests();
149 resetGitStatusStateForTests();150 resetGitStatusStateForTests();
151 resetSourceControlDiscoveryStateForTests();
150 resetRequestLatencyStateForTests();152 resetRequestLatencyStateForTests();
151 resetSavedEnvironmentRegistryStoreForTests();153 resetSavedEnvironmentRegistryStoreForTests();
152 resetSavedEnvironmentRuntimeStoreForTests();154 resetSavedEnvironmentRuntimeStoreForTests();
bun.lock+1−0
@@ -131,6 +131,7 @@ · 130 unmodified lines
131 "version": "0.0.0-alpha.1",131 "version": "0.0.0-alpha.1",
132 "dependencies": {132 "dependencies": {
133 "@t3tools/contracts": "workspace:*",133 "@t3tools/contracts": "workspace:*",
134 "effect": "catalog:",
134 },135 },
135 "devDependencies": {136 "devDependencies": {
136 "@effect/language-service": "catalog:",137 "@effect/language-service": "catalog:",
packages/client-runtime/package.json+2−1
@@ -15,7 +15,8 @@ · 14 unmodified lines
15 "test": "vitest run"15 "test": "vitest run"
16 },16 },
17 "dependencies": {17 "dependencies": {
18 "@t3tools/contracts": "workspace:*"18 "@t3tools/contracts": "workspace:*",
19 "effect": "catalog:"
19 },20 },
20 "devDependencies": {21 "devDependencies": {
21 "@effect/language-service": "catalog:",22 "@effect/language-service": "catalog:",
packages/client-runtime/src/index.ts+1−0
@@ -1,2 +1,3 @@
1export * from "./knownEnvironment.ts";1export * from "./knownEnvironment.ts";
2export * from "./scoped.ts";2export * from "./scoped.ts";
3export * from "./sourceControlDiscoveryState.ts";
packages/client-runtime/src/sourceControlDiscoveryState.test.ts+107−0
@@ -0,0 +1,107 @@
1import { assert, beforeEach, it } from "vitest";
2import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
7 createSourceControlDiscoveryManager,
8} from "./sourceControlDiscoveryState.ts";
9
10const EMPTY_RESULT: SourceControlDiscoveryResult = {
11 versionControlSystems: [],
12 sourceControlProviders: [],
13};
14
15function unresolvedDiscovery() {
16 throw new Error("Discovery resolver was not initialized.");
17}
18
19let registry = AtomRegistry.make();
20
21beforeEach(() => {
22 registry.dispose();
23 registry = AtomRegistry.make();
24});
25
26it("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
46it("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
82it("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 @@
1import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3
4/* --- Types ---------------------------------------------------------- */
5
6export interface SourceControlDiscoveryState {
7 readonly data: SourceControlDiscoveryResult | null;
8 readonly error: string | null;
9 readonly isPending: boolean;
10}
11
12export interface SourceControlDiscoveryTarget {
13 readonly key: string | null;
14}
15
16export interface SourceControlDiscoveryClient {
17 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
18}
19
20/* --- Constants ------------------------------------------------------ */
21
22export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
23 data: null,
24 error: null,
25 isPending: false,
26});
27
28const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
29 data: null,
30 error: null,
31 isPending: true,
32});
33
34/* --- Atoms ---------------------------------------------------------- */
35
36const knownSourceControlDiscoveryKeys = new Set<string>();
37
38export 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
46export 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
52export 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
61export 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
76export 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
46export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]);46export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]);
47export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type;47export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type;
48 48
49export const SourceControlProviderAuthStatus = Schema.Literals([
50 "authenticated",
51 "unauthenticated",
52 "unknown",
53]);
54export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type;
55
56export const SourceControlProviderAuth = Schema.Struct({
57 status: SourceControlProviderAuthStatus,
58 account: Schema.Option(TrimmedNonEmptyString),
59 host: Schema.Option(TrimmedNonEmptyString),
60 detail: Schema.Option(TrimmedNonEmptyString),
61});
62export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
63
49const SourceControlDiscoveryItemFields = {64const SourceControlDiscoveryItemFields = {
50 label: TrimmedNonEmptyString,65 label: TrimmedNonEmptyString,
51 executable: TrimmedNonEmptyString,66 executable: TrimmedNonEmptyString,
@@ -71,6 +86,7 @@ export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; · 19 unmodified lines
71export const SourceControlProviderDiscoveryItem = Schema.Struct({86export const SourceControlProviderDiscoveryItem = Schema.Struct({
72 kind: SourceControlProviderKind,87 kind: SourceControlProviderKind,
73 ...SourceControlDiscoveryItemFields,88 ...SourceControlDiscoveryItemFields,
89 auth: SourceControlProviderAuth,
74});90});
75export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type;91export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type;
76 92