History

Add source control discovery state

Julius Marminge committed 6 months ago

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

Showing the changes to apps/server/src/sourceControl (2 of 13 files). Show all

Unified · Split

2 files
  1. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +147 −9
  2. apps/server/src/sourceControl/SourceControlDiscovery.ts +257 −15
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" },