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 @@
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" },