Showing the changes to apps/server/src/sourceControl (2 of 13 files). Show all
Unified · Split
| @@ -1,6 +1,6 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { Effect, Layer } from "effect"; | 3 | import { Effect, Layer, Option } from "effect"; |
| 4 | import { ChildProcessSpawner } from "effect/unstable/process"; | 4 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; | 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 6 | 6 | ||
| @@ -8,10 +8,16 @@ import { ServerConfig } from "../config.ts"; · 1 unmodified line | |||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; | 9 | import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; |
| 10 | 10 | ||
| 11 | const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ | 11 | const 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 | ||
| 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 | } | ||
| 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 | |||
| 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, | ||
| 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)); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 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 | ||
| 25 | type ProviderProbe = DiscoveryProbe & { | 26 | type 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 | ||
| 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 | |||
| 29 | const VCS_PROBES: ReadonlyArray<VcsProbe> = [ | 49 | const 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 | ||
| 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 | |||
| 108 | export interface SourceControlDiscoveryShape { | 308 | export 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 | process | 326 | 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" }, |