Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | −import { Effect, Layer } from "effect"; | |
| 3 | +import { Effect, Layer, Option } from "effect"; | |
| 4 | 4 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 6 | 6 | |
| @@ -8,10 +8,16 @@ import { ServerConfig } from "../config.ts"; · 1 unmodified line | ||
| 8 | 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | 9 | import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; |
| 10 | 10 | |
| 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), | |
| 13 | 19 | stdout, |
| 14 | − stderr: "", | |
| 20 | + stderr: options?.stderr ?? "", | |
| 15 | 21 | stdoutTruncated: false, |
| 16 | 22 | stderrTruncated: false, |
| 17 | 23 | }); |
| @@ -27,9 +33,20 @@ it.effect("reports implemented tools separately from locally available CLIs", () · 9 unmodified lines | ||
| 27 | 33 | if (input.command === "git") { |
| 28 | 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 | 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 | 50 | return Effect.fail( |
| 34 | 51 | new VcsProcessSpawnError({ |
| 35 | 52 | operation: input.operation, |
| @@ -65,12 +82,133 @@ it.effect("reports implemented tools separately from locally available CLIs", () · 29 unmodified lines | ||
| 65 | 82 | kind: item.kind, |
| 66 | 83 | implemented: item.implemented, |
| 67 | 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" }, | |
| 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 | + }, | |
| 74 | 212 | ], |
| 75 | 213 | ); |
| 76 | 214 | }).pipe(Effect.provide(testLayer)); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type SourceControlProviderAuth, | |
| 2 | 3 | type SourceControlDiscoveryResult, |
| 3 | 4 | type SourceControlProviderDiscoveryItem, |
| 4 | 5 | type SourceControlProviderKind, |
| @@ -24,8 +25,27 @@ type VcsProbe = DiscoveryProbe & { · 19 unmodified lines | ||
| 24 | 25 | |
| 25 | 26 | type ProviderProbe = DiscoveryProbe & { |
| 26 | 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 | 49 | const VCS_PROBES: ReadonlyArray<VcsProbe> = [ |
| 30 | 50 | { |
| 31 | 51 | kind: "git", |
| @@ -59,6 +79,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 27 unmodified lines | ||
| 59 | 79 | label: "GitHub", |
| 60 | 80 | executable: "gh", |
| 61 | 81 | versionArgs: ["--version"], |
| 82 | + authArgs: ["auth", "status"], | |
| 83 | + parseAuth: parseGitHubAuth, | |
| 62 | 84 | implemented: true, |
| 63 | 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 | 89 | label: "GitLab", |
| 68 | 90 | executable: "glab", |
| 69 | 91 | versionArgs: ["--version"], |
| 92 | + authArgs: ["auth", "status"], | |
| 93 | + parseAuth: parseGitLabAuth, | |
| 70 | 94 | implemented: false, |
| 71 | 95 | installHint: |
| 72 | 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 | 100 | label: "Azure DevOps", |
| 77 | 101 | executable: "az", |
| 78 | 102 | versionArgs: ["--version"], |
| 103 | + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | |
| 104 | + parseAuth: parseAzureAuth, | |
| 79 | 105 | implemented: false, |
| 80 | 106 | installHint: |
| 81 | 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 | 111 | label: "Bitbucket", |
| 86 | 112 | executable: "bb", |
| 87 | 113 | versionArgs: ["--version"], |
| 114 | + authArgs: ["auth", "status"], | |
| 115 | + parseAuth: parseBitbucketAuth, | |
| 88 | 116 | implemented: false, |
| 89 | 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 | 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 | 308 | export interface SourceControlDiscoveryShape { |
| 109 | 309 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 110 | 310 | } |
| @@ -122,7 +322,7 @@ export const layer = Layer.effect( · 11 unmodified lines | ||
| 122 | 322 | |
| 123 | 323 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( |
| 124 | 324 | input: DiscoveryProbe & { readonly kind: Kind }, |
| 125 | − ) => | |
| 325 | + ): Effect.Effect<DiscoveryProbeResult<Kind>> => | |
| 126 | 326 | process |
| 127 | 327 | .run({ |
| 128 | 328 | operation: "source-control.discovery.probe", |
| @@ -134,18 +334,21 @@ export const layer = Layer.effect( · 5 unmodified lines | ||
| 134 | 334 | truncateOutputAtMaxBytes: true, |
| 135 | 335 | }) |
| 136 | 336 | .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 | + ), | |
| 149 | 352 | Effect.catch((cause) => |
| 150 | 353 | Effect.succeed({ |
| 151 | 354 | kind: input.kind, |
| @@ -156,10 +359,49 @@ export const layer = Layer.effect( · 4 unmodified lines | ||
| 156 | 359 | version: Option.none<string>(), |
| 157 | 360 | installHint: input.installHint, |
| 158 | 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 | 405 | return SourceControlDiscovery.of({ |
| 164 | 406 | discover: Effect.all({ |
| 165 | 407 | versionControlSystems: Effect.all( |
| @@ -167,7 +409,7 @@ export const layer = Layer.effect( · 1 unmodified line | ||
| 167 | 409 | { concurrency: "unbounded" }, |
| 168 | 410 | ), |
| 169 | 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 | 413 | Effect.Effect<SourceControlProviderDiscoveryItem> |
| 172 | 414 | >, |
| 173 | 415 | { concurrency: "unbounded" }, |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; |
| 4 | −import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 4 | +import { useEffect, useState, type ReactNode } from "react"; | |
| 5 | 5 | import { |
| 6 | 6 | isProviderDriverKind, |
| 7 | 7 | type ProviderInstanceConfig, |
| @@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta"; · 16 unmodified lines | ||
| 24 | 24 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| 25 | 25 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 26 | 26 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 27 | +import { RedactedSensitiveText } from "./RedactedSensitiveText"; | |
| 27 | 28 | import { |
| 28 | 29 | PROVIDER_STATUS_STYLES, |
| 29 | 30 | getProviderSummary, |
| @@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [ · 12 unmodified lines | ||
| 42 | 43 | |
| 43 | 44 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 44 | 45 | |
| 45 | −const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; | |
| 46 | − | |
| 47 | 46 | let environmentVariableDraftId = 0; |
| 48 | 47 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 49 | 48 | |
| @@ -68,25 +67,6 @@ function makeEnvironmentDraftRow( · 18 unmodified lines | ||
| 68 | 67 | }; |
| 69 | 68 | } |
| 70 | 69 | |
| 71 | −function redactedEmailPlaceholder(email: string): string { | |
| 72 | − let state = 0x811c9dc5; | |
| 73 | − for (let index = 0; index < email.length; index += 1) { | |
| 74 | − state ^= email.charCodeAt(index); | |
| 75 | − state = Math.imul(state, 0x01000193); | |
| 76 | − } | |
| 77 | − | |
| 78 | − const nextChar = () => { | |
| 79 | − state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); | |
| 80 | − state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); | |
| 81 | − return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x"; | |
| 82 | − }; | |
| 83 | − | |
| 84 | − return Array.from(email, (char) => { | |
| 85 | − if (char === "@" || char === "." || char === "-" || char === "_") return char; | |
| 86 | − return nextChar(); | |
| 87 | − }).join(""); | |
| 88 | −} | |
| 89 | − | |
| 90 | 70 | /** |
| 91 | 71 | * Read a string[] at `key` from the opaque config blob, filtering out |
| 92 | 72 | * non-string entries. Used for `customModels`, which is always typed as |
| @@ -146,35 +126,19 @@ function ProviderAuthEmail(props: { · 53 unmodified lines | ||
| 146 | 126 | readonly prefix?: string; |
| 147 | 127 | readonly separator?: boolean; |
| 148 | 128 | }) { |
| 149 | − const [revealed, setRevealed] = useState(false); | |
| 150 | 129 | const trimmed = props.email?.trim(); |
| 151 | − const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]); | |
| 152 | 130 | if (!trimmed) return null; |
| 153 | 131 | |
| 154 | 132 | return ( |
| 155 | 133 | <span className="inline-flex min-w-0 items-center gap-1.5"> |
| 156 | 134 | {props.separator ? <span aria-hidden>·</span> : null} |
| 157 | 135 | {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null} |
| 158 | − <Tooltip> | |
| 159 | − <TooltipTrigger | |
| 160 | − render={ | |
| 161 | − <button | |
| 162 | − type="button" | |
| 163 | − className={cn( | |
| 164 | − "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground", | |
| 165 | − revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]", | |
| 166 | − )} | |
| 167 | − onClick={() => setRevealed((value) => !value)} | |
| 168 | − aria-label={revealed ? "Hide account email" : "Reveal account email"} | |
| 169 | − > | |
| 170 | − {revealed ? trimmed : redacted} | |
| 171 | − </button> | |
| 172 | − } | |
| 173 | − /> | |
| 174 | − <TooltipPopup side="top"> | |
| 175 | − {revealed ? "Click to hide email" : "Click to reveal email"} | |
| 176 | − </TooltipPopup> | |
| 177 | − </Tooltip> | |
| 136 | + <RedactedSensitiveText | |
| 137 | + value={trimmed} | |
| 138 | + ariaLabel="Toggle account email visibility" | |
| 139 | + revealTooltip="Click to reveal email" | |
| 140 | + hideTooltip="Click to hide email" | |
| 141 | + /> | |
| 178 | 142 | </span> |
| 179 | 143 | ); |
| 180 | 144 | } |
| @@ -0,0 +1,61 @@ | ||
| 1 | +import { useMemo, useState } from "react"; | |
| 2 | + | |
| 3 | +import { cn } from "../../lib/utils"; | |
| 4 | +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 5 | + | |
| 6 | +const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; | |
| 7 | + | |
| 8 | +function redactedPlaceholder(value: string): string { | |
| 9 | + let state = 0x811c9dc5; | |
| 10 | + for (let index = 0; index < value.length; index += 1) { | |
| 11 | + state ^= value.charCodeAt(index); | |
| 12 | + state = Math.imul(state, 0x01000193); | |
| 13 | + } | |
| 14 | + | |
| 15 | + const nextChar = () => { | |
| 16 | + state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); | |
| 17 | + state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); | |
| 18 | + return REDACTED_TEXT_ALPHABET[Math.abs(state) % REDACTED_TEXT_ALPHABET.length] ?? "x"; | |
| 19 | + }; | |
| 20 | + | |
| 21 | + return Array.from(value, (char) => { | |
| 22 | + if (char === "@" || char === "." || char === "-" || char === "_") return char; | |
| 23 | + return nextChar(); | |
| 24 | + }).join(""); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function RedactedSensitiveText(props: { | |
| 28 | + readonly value: string | null | undefined; | |
| 29 | + readonly ariaLabel: string; | |
| 30 | + readonly revealTooltip: string; | |
| 31 | + readonly hideTooltip: string; | |
| 32 | + readonly className?: string; | |
| 33 | +}) { | |
| 34 | + const [revealed, setRevealed] = useState(false); | |
| 35 | + const value = props.value?.trim(); | |
| 36 | + const redacted = useMemo(() => (value ? redactedPlaceholder(value) : ""), [value]); | |
| 37 | + | |
| 38 | + if (!value) return null; | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <Tooltip> | |
| 42 | + <TooltipTrigger | |
| 43 | + render={ | |
| 44 | + <button | |
| 45 | + type="button" | |
| 46 | + className={cn( | |
| 47 | + "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground", | |
| 48 | + revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]", | |
| 49 | + props.className, | |
| 50 | + )} | |
| 51 | + onClick={() => setRevealed((current) => !current)} | |
| 52 | + aria-label={props.ariaLabel} | |
| 53 | + > | |
| 54 | + {revealed ? value : redacted} | |
| 55 | + </button> | |
| 56 | + } | |
| 57 | + /> | |
| 58 | + <TooltipPopup side="top">{revealed ? props.hideTooltip : props.revealTooltip}</TooltipPopup> | |
| 59 | + </Tooltip> | |
| 60 | + ); | |
| 61 | +} | |
| @@ -1,30 +1,26 @@ | ||
| 1 | 1 | import { ChevronDownIcon, GitBranchIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | 2 | import { Option } from "effect"; |
| 3 | −import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | +import { useMemo, useState } from "react"; | |
| 4 | 4 | import type { |
| 5 | 5 | SourceControlDiscoveryItem, |
| 6 | 6 | SourceControlDiscoveryResult, |
| 7 | + SourceControlProviderAuth, | |
| 7 | 8 | SourceControlProviderDiscoveryItem, |
| 8 | 9 | VcsDiscoveryItem, |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | |
| 11 | −import { ensureLocalApi } from "../../localApi"; | |
| 12 | 12 | import { cn } from "../../lib/utils"; |
| 13 | +import { | |
| 14 | + refreshSourceControlDiscovery, | |
| 15 | + useSourceControlDiscovery, | |
| 16 | +} from "../../lib/sourceControlDiscoveryState"; | |
| 13 | 17 | import { Badge } from "../ui/badge"; |
| 14 | 18 | import { Button } from "../ui/button"; |
| 15 | 19 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; |
| 16 | 20 | import { Switch } from "../ui/switch"; |
| 21 | +import { RedactedSensitiveText } from "./RedactedSensitiveText"; | |
| 17 | 22 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 18 | 23 | |
| 19 | −type DiscoveryLoadState = | |
| 20 | − | { readonly status: "loading"; readonly result: SourceControlDiscoveryResult | null } | |
| 21 | − | { readonly status: "ready"; readonly result: SourceControlDiscoveryResult } | |
| 22 | − | { | |
| 23 | − readonly status: "error"; | |
| 24 | − readonly result: SourceControlDiscoveryResult | null; | |
| 25 | − readonly message: string; | |
| 26 | − }; | |
| 27 | − | |
| 28 | 24 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 29 | 25 | versionControlSystems: [], |
| 30 | 26 | sourceControlProviders: [], |
| @@ -34,6 +30,46 @@ function optionLabel(value: Option.Option<string>): string | null { · 3 unmodified lines | ||
| 34 | 30 | return Option.getOrNull(value); |
| 35 | 31 | } |
| 36 | 32 | |
| 33 | +function isProviderDiscoveryItem( | |
| 34 | + item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, | |
| 35 | +): item is SourceControlProviderDiscoveryItem { | |
| 36 | + return "auth" in item; | |
| 37 | +} | |
| 38 | + | |
| 39 | +function authPresentation(auth: SourceControlProviderAuth): { | |
| 40 | + readonly label: string; | |
| 41 | + readonly badge: "success" | "warning" | "outline"; | |
| 42 | +} { | |
| 43 | + if (auth.status === "authenticated") { | |
| 44 | + return { label: "Signed in", badge: "success" }; | |
| 45 | + } | |
| 46 | + if (auth.status === "unauthenticated") { | |
| 47 | + return { label: "Sign in", badge: "warning" }; | |
| 48 | + } | |
| 49 | + return { label: "Auth unknown", badge: "outline" }; | |
| 50 | +} | |
| 51 | + | |
| 52 | +function authSummary(auth: SourceControlProviderAuth): string { | |
| 53 | + if (auth.status === "authenticated") { | |
| 54 | + return "Authenticated"; | |
| 55 | + } | |
| 56 | + if (auth.status === "unauthenticated") { | |
| 57 | + return "Not authenticated"; | |
| 58 | + } | |
| 59 | + return "Auth not checked"; | |
| 60 | +} | |
| 61 | + | |
| 62 | +function RedactedAccount(props: { readonly account: string | null }) { | |
| 63 | + return ( | |
| 64 | + <RedactedSensitiveText | |
| 65 | + value={props.account} | |
| 66 | + ariaLabel="Toggle source control account visibility" | |
| 67 | + revealTooltip="Click to reveal account" | |
| 68 | + hideTooltip="Click to hide account" | |
| 69 | + /> | |
| 70 | + ); | |
| 71 | +} | |
| 72 | + | |
| 37 | 73 | function statusPresentation(item: SourceControlDiscoveryItem): { |
| 38 | 74 | readonly label: string; |
| 39 | 75 | readonly badge: "success" | "warning" | "outline"; |
| @@ -80,6 +116,11 @@ function DiscoveryItemRow({ · 40 unmodified lines | ||
| 80 | 116 | const version = optionLabel(item.version); |
| 81 | 117 | const detail = optionLabel(item.detail); |
| 82 | 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 | 125 | return ( |
| 85 | 126 | <div |
| @@ -104,6 +145,11 @@ function DiscoveryItemRow({ · 18 unmodified lines | ||
| 104 | 145 | Not in this branch |
| 105 | 146 | </Badge> |
| 106 | 147 | ) : null} |
| 148 | + {authStatus ? ( | |
| 149 | + <Badge variant={authStatus.badge} size="sm"> | |
| 150 | + {authStatus.label} | |
| 151 | + </Badge> | |
| 152 | + ) : null} | |
| 107 | 153 | </div> |
| 108 | 154 | <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> |
| 109 | 155 | <span>CLI</span> |
| @@ -111,6 +157,13 @@ function DiscoveryItemRow({ · 1 unmodified line | ||
| 111 | 157 | {item.executable} |
| 112 | 158 | </code> |
| 113 | 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 | 167 | </p> |
| 115 | 168 | </div> |
| 116 | 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 | 191 | <dd className="min-w-0 font-mono text-foreground">{item.executable}</dd> |
| 139 | 192 | <dt className="text-muted-foreground">Version</dt> |
| 140 | 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 | 211 | <dt className="text-muted-foreground">Install</dt> |
| 142 | 212 | <dd className="min-w-0 leading-relaxed text-foreground">{item.installHint}</dd> |
| 143 | 213 | <dt className="text-muted-foreground">Build</dt> |
| @@ -161,49 +231,15 @@ function DiscoveryItemRow({ · 17 unmodified lines | ||
| 161 | 231 | } |
| 162 | 232 | |
| 163 | 233 | export function SourceControlSettingsPanel() { |
| 164 | − const [loadState, setLoadState] = useState<DiscoveryLoadState>({ | |
| 165 | − status: "loading", | |
| 166 | − result: null, | |
| 167 | − }); | |
| 168 | − const latestResultRef = useRef<SourceControlDiscoveryResult | null>(null); | |
| 234 | + const discovery = useSourceControlDiscovery(); | |
| 169 | 235 | const [expanded, setExpanded] = useState<Readonly<Record<string, boolean>>>({}); |
| 170 | 236 | |
| 171 | − const refresh = useCallback((options?: { readonly signal?: AbortSignal }) => { | |
| 172 | − const previous = latestResultRef.current; | |
| 173 | − setLoadState({ status: "loading", result: previous }); | |
| 174 | − | |
| 175 | − ensureLocalApi() | |
| 176 | − .server.discoverSourceControl() | |
| 177 | − .then((result) => { | |
| 178 | − if (!options?.signal?.aborted) { | |
| 179 | − latestResultRef.current = result; | |
| 180 | − setLoadState({ status: "ready", result }); | |
| 181 | − } | |
| 182 | − }) | |
| 183 | − .catch((cause: unknown) => { | |
| 184 | − if (!options?.signal?.aborted) { | |
| 185 | − setLoadState({ | |
| 186 | − status: "error", | |
| 187 | − result: previous, | |
| 188 | − message: | |
| 189 | − cause instanceof Error ? cause.message : "Failed to discover source control tools.", | |
| 190 | − }); | |
| 191 | − } | |
| 192 | − }); | |
| 193 | − }, []); | |
| 194 | − | |
| 195 | − useEffect(() => { | |
| 196 | − const controller = new AbortController(); | |
| 197 | − refresh({ signal: controller.signal }); | |
| 198 | − return () => controller.abort(); | |
| 199 | − }, [refresh]); | |
| 200 | − | |
| 201 | − const result = loadState.result ?? EMPTY_DISCOVERY_RESULT; | |
| 237 | + const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; | |
| 202 | 238 | const statusText = useMemo(() => { |
| 203 | − if (loadState.status === "loading") return "Scanning installed tools..."; | |
| 204 | − if (loadState.status === "error") return loadState.message; | |
| 239 | + if (discovery.isPending) return "Scanning installed tools..."; | |
| 240 | + if (discovery.error) return discovery.error; | |
| 205 | 241 | return "Detected source control tools on this system."; |
| 206 | − }, [loadState]); | |
| 242 | + }, [discovery.error, discovery.isPending]); | |
| 207 | 243 | |
| 208 | 244 | const setItemExpanded = (key: string, value: boolean) => { |
| 209 | 245 | setExpanded((current) => ({ ...current, [key]: value })); |
| @@ -224,12 +260,12 @@ export function SourceControlSettingsPanel() { · 14 unmodified lines | ||
| 224 | 260 | size="sm" |
| 225 | 261 | variant="ghost" |
| 226 | 262 | className="h-7 gap-1.5 px-2 text-xs" |
| 227 | − onClick={() => refresh()} | |
| 228 | − disabled={loadState.status === "loading"} | |
| 263 | + onClick={() => { | |
| 264 | + void refreshSourceControlDiscovery(); | |
| 265 | + }} | |
| 266 | + disabled={discovery.isPending} | |
| 229 | 267 | > |
| 230 | − <RefreshCwIcon | |
| 231 | − className={cn("size-3.5", loadState.status === "loading" && "animate-spin")} | |
| 232 | − /> | |
| 268 | + <RefreshCwIcon className={cn("size-3.5", discovery.isPending && "animate-spin")} /> | |
| 233 | 269 | Scan |
| 234 | 270 | </Button> |
| 235 | 271 | } |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { | |
| 3 | + EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | |
| 4 | + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | |
| 5 | + type SourceControlDiscoveryState, | |
| 6 | + createSourceControlDiscoveryManager, | |
| 7 | + getSourceControlDiscoveryTargetKey, | |
| 8 | + sourceControlDiscoveryStateAtom, | |
| 9 | +} from "@t3tools/client-runtime"; | |
| 10 | +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | |
| 11 | +import { useEffect } from "react"; | |
| 12 | + | |
| 13 | +import { readLocalApi } from "../localApi"; | |
| 14 | +import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 15 | + | |
| 16 | +const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; | |
| 17 | + | |
| 18 | +export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | |
| 19 | + getRegistry: () => appAtomRegistry, | |
| 20 | + getClient: () => readLocalApi()?.server ?? null, | |
| 21 | +}); | |
| 22 | + | |
| 23 | +export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> { | |
| 24 | + return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function resetSourceControlDiscoveryStateForTests(): void { | |
| 28 | + sourceControlDiscoveryManager.reset(); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function useSourceControlDiscovery(): SourceControlDiscoveryState { | |
| 32 | + const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); | |
| 33 | + | |
| 34 | + useEffect(() => { | |
| 35 | + void sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); | |
| 36 | + }, []); | |
| 37 | + | |
| 38 | + const state = useAtomValue( | |
| 39 | + targetKey !== null | |
| 40 | + ? sourceControlDiscoveryStateAtom(targetKey) | |
| 41 | + : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | |
| 42 | + ); | |
| 43 | + return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; | |
| 44 | +} | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; |
| 4 | +import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; | |
| 4 | 5 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 5 | 6 | import { resetServerStateForTests } from "./rpc/serverState"; |
| 6 | 7 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; |
| @@ -147,6 +148,7 @@ export async function __resetLocalApiForTests() { · 140 unmodified lines | ||
| 147 | 148 | __resetClientSettingsPersistenceForTests(); |
| 148 | 149 | await resetEnvironmentServiceForTests(); |
| 149 | 150 | resetGitStatusStateForTests(); |
| 151 | + resetSourceControlDiscoveryStateForTests(); | |
| 150 | 152 | resetRequestLatencyStateForTests(); |
| 151 | 153 | resetSavedEnvironmentRegistryStoreForTests(); |
| 152 | 154 | resetSavedEnvironmentRuntimeStoreForTests(); |
| @@ -131,6 +131,7 @@ · 130 unmodified lines | ||
| 131 | 131 | "version": "0.0.0-alpha.1", |
| 132 | 132 | "dependencies": { |
| 133 | 133 | "@t3tools/contracts": "workspace:*", |
| 134 | + "effect": "catalog:", | |
| 134 | 135 | }, |
| 135 | 136 | "devDependencies": { |
| 136 | 137 | "@effect/language-service": "catalog:", |
| @@ -15,7 +15,8 @@ · 14 unmodified lines | ||
| 15 | 15 | "test": "vitest run" |
| 16 | 16 | }, |
| 17 | 17 | "dependencies": { |
| 18 | − "@t3tools/contracts": "workspace:*" | |
| 18 | + "@t3tools/contracts": "workspace:*", | |
| 19 | + "effect": "catalog:" | |
| 19 | 20 | }, |
| 20 | 21 | "devDependencies": { |
| 21 | 22 | "@effect/language-service": "catalog:", |
| @@ -1,2 +1,3 @@ | ||
| 1 | 1 | export * from "./knownEnvironment.ts"; |
| 2 | 2 | export * from "./scoped.ts"; |
| 3 | +export * from "./sourceControlDiscoveryState.ts"; | |
| @@ -0,0 +1,107 @@ | ||
| 1 | +import { assert, beforeEach, it } from "vitest"; | |
| 2 | +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | |
| 3 | +import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | |
| 7 | + createSourceControlDiscoveryManager, | |
| 8 | +} from "./sourceControlDiscoveryState.ts"; | |
| 9 | + | |
| 10 | +const EMPTY_RESULT: SourceControlDiscoveryResult = { | |
| 11 | + versionControlSystems: [], | |
| 12 | + sourceControlProviders: [], | |
| 13 | +}; | |
| 14 | + | |
| 15 | +function unresolvedDiscovery() { | |
| 16 | + throw new Error("Discovery resolver was not initialized."); | |
| 17 | +} | |
| 18 | + | |
| 19 | +let registry = AtomRegistry.make(); | |
| 20 | + | |
| 21 | +beforeEach(() => { | |
| 22 | + registry.dispose(); | |
| 23 | + registry = AtomRegistry.make(); | |
| 24 | +}); | |
| 25 | + | |
| 26 | +it("stores refreshed discovery data in an atom snapshot", async () => { | |
| 27 | + const manager = createSourceControlDiscoveryManager({ | |
| 28 | + getRegistry: () => registry, | |
| 29 | + getClient: () => ({ | |
| 30 | + discoverSourceControl: async () => EMPTY_RESULT, | |
| 31 | + }), | |
| 32 | + }); | |
| 33 | + | |
| 34 | + assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); | |
| 35 | + | |
| 36 | + const result = await manager.refresh({ key: "primary" }); | |
| 37 | + | |
| 38 | + assert.strictEqual(result, EMPTY_RESULT); | |
| 39 | + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | |
| 40 | + data: EMPTY_RESULT, | |
| 41 | + error: null, | |
| 42 | + isPending: false, | |
| 43 | + }); | |
| 44 | +}); | |
| 45 | + | |
| 46 | +it("deduplicates in-flight discovery refreshes by target key", async () => { | |
| 47 | + let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; | |
| 48 | + let calls = 0; | |
| 49 | + const manager = createSourceControlDiscoveryManager({ | |
| 50 | + getRegistry: () => registry, | |
| 51 | + getClient: () => ({ | |
| 52 | + discoverSourceControl: () => { | |
| 53 | + calls += 1; | |
| 54 | + return new Promise<SourceControlDiscoveryResult>((resolve) => { | |
| 55 | + resolveDiscovery = resolve; | |
| 56 | + }); | |
| 57 | + }, | |
| 58 | + }), | |
| 59 | + }); | |
| 60 | + | |
| 61 | + const first = manager.refresh({ key: "primary" }); | |
| 62 | + const second = manager.refresh({ key: "primary" }); | |
| 63 | + | |
| 64 | + assert.strictEqual(first, second); | |
| 65 | + assert.strictEqual(calls, 1); | |
| 66 | + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | |
| 67 | + data: null, | |
| 68 | + error: null, | |
| 69 | + isPending: true, | |
| 70 | + }); | |
| 71 | + | |
| 72 | + resolveDiscovery(EMPTY_RESULT); | |
| 73 | + await first; | |
| 74 | + | |
| 75 | + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | |
| 76 | + data: EMPTY_RESULT, | |
| 77 | + error: null, | |
| 78 | + isPending: false, | |
| 79 | + }); | |
| 80 | +}); | |
| 81 | + | |
| 82 | +it("keeps the previous snapshot when refresh fails", async () => { | |
| 83 | + let shouldFail = false; | |
| 84 | + const manager = createSourceControlDiscoveryManager({ | |
| 85 | + getRegistry: () => registry, | |
| 86 | + getClient: () => ({ | |
| 87 | + discoverSourceControl: async () => { | |
| 88 | + if (shouldFail) { | |
| 89 | + throw new Error("probe failed"); | |
| 90 | + } | |
| 91 | + return EMPTY_RESULT; | |
| 92 | + }, | |
| 93 | + }), | |
| 94 | + }); | |
| 95 | + | |
| 96 | + await manager.refresh({ key: "primary" }); | |
| 97 | + shouldFail = true; | |
| 98 | + | |
| 99 | + const result = await manager.refresh({ key: "primary" }); | |
| 100 | + | |
| 101 | + assert.strictEqual(result, EMPTY_RESULT); | |
| 102 | + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | |
| 103 | + data: EMPTY_RESULT, | |
| 104 | + error: "probe failed", | |
| 105 | + isPending: false, | |
| 106 | + }); | |
| 107 | +}); | |
| @@ -0,0 +1,206 @@ | ||
| 1 | +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | |
| 2 | +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | |
| 3 | + | |
| 4 | +/* --- Types ---------------------------------------------------------- */ | |
| 5 | + | |
| 6 | +export interface SourceControlDiscoveryState { | |
| 7 | + readonly data: SourceControlDiscoveryResult | null; | |
| 8 | + readonly error: string | null; | |
| 9 | + readonly isPending: boolean; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface SourceControlDiscoveryTarget { | |
| 13 | + readonly key: string | null; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export interface SourceControlDiscoveryClient { | |
| 17 | + readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/* --- Constants ------------------------------------------------------ */ | |
| 21 | + | |
| 22 | +export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | |
| 23 | + data: null, | |
| 24 | + error: null, | |
| 25 | + isPending: false, | |
| 26 | +}); | |
| 27 | + | |
| 28 | +const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | |
| 29 | + data: null, | |
| 30 | + error: null, | |
| 31 | + isPending: true, | |
| 32 | +}); | |
| 33 | + | |
| 34 | +/* --- Atoms ---------------------------------------------------------- */ | |
| 35 | + | |
| 36 | +const knownSourceControlDiscoveryKeys = new Set<string>(); | |
| 37 | + | |
| 38 | +export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { | |
| 39 | + knownSourceControlDiscoveryKeys.add(key); | |
| 40 | + return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( | |
| 41 | + Atom.keepAlive, | |
| 42 | + Atom.withLabel(`source-control-discovery:${key}`), | |
| 43 | + ); | |
| 44 | +}); | |
| 45 | + | |
| 46 | +export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( | |
| 47 | + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | |
| 48 | +).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); | |
| 49 | + | |
| 50 | +/* --- Helpers -------------------------------------------------------- */ | |
| 51 | + | |
| 52 | +export function getSourceControlDiscoveryTargetKey( | |
| 53 | + target: SourceControlDiscoveryTarget, | |
| 54 | +): string | null { | |
| 55 | + const key = target.key?.trim(); | |
| 56 | + return key && key.length > 0 ? key : null; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/* --- Refresh manager ------------------------------------------------ */ | |
| 60 | + | |
| 61 | +export interface SourceControlDiscoveryManagerConfig { | |
| 62 | + /** | |
| 63 | + * Get the atom registry used to read/write source-control discovery snapshots. | |
| 64 | + */ | |
| 65 | + readonly getRegistry: () => AtomRegistry.AtomRegistry; | |
| 66 | + /** | |
| 67 | + * Resolve the runtime client for a discovery target key. | |
| 68 | + * | |
| 69 | + * Web currently uses a single `"primary"` target, but keeping this keyed | |
| 70 | + * lets mobile or future multi-environment clients provide separate discovery | |
| 71 | + * clients without changing the state primitive. | |
| 72 | + */ | |
| 73 | + readonly getClient: (key: string) => SourceControlDiscoveryClient | null; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) { | |
| 77 | + const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>(); | |
| 78 | + | |
| 79 | + /* -- Atom helpers -------------------------------------------------- */ | |
| 80 | + | |
| 81 | + function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { | |
| 82 | + config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); | |
| 83 | + } | |
| 84 | + | |
| 85 | + function markPending(targetKey: string): void { | |
| 86 | + const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | |
| 87 | + const next: SourceControlDiscoveryState = | |
| 88 | + current.data === null | |
| 89 | + ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE | |
| 90 | + : { | |
| 91 | + data: current.data, | |
| 92 | + error: null, | |
| 93 | + isPending: true, | |
| 94 | + }; | |
| 95 | + | |
| 96 | + if ( | |
| 97 | + current.data === next.data && | |
| 98 | + current.error === next.error && | |
| 99 | + current.isPending === next.isPending | |
| 100 | + ) { | |
| 101 | + return; | |
| 102 | + } | |
| 103 | + | |
| 104 | + setState(targetKey, next); | |
| 105 | + } | |
| 106 | + | |
| 107 | + function setData(targetKey: string, data: SourceControlDiscoveryResult): void { | |
| 108 | + setState(targetKey, { | |
| 109 | + data, | |
| 110 | + error: null, | |
| 111 | + isPending: false, | |
| 112 | + }); | |
| 113 | + } | |
| 114 | + | |
| 115 | + function setError(targetKey: string, error: unknown): void { | |
| 116 | + const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | |
| 117 | + setState(targetKey, { | |
| 118 | + data: current.data, | |
| 119 | + error: error instanceof Error ? error.message : "Failed to discover source control tools.", | |
| 120 | + isPending: false, | |
| 121 | + }); | |
| 122 | + } | |
| 123 | + | |
| 124 | + /* -- Public API ---------------------------------------------------- */ | |
| 125 | + | |
| 126 | + /** | |
| 127 | + * Trigger a one-shot source-control discovery RPC for a target. | |
| 128 | + * | |
| 129 | + * Calls are deduplicated while a refresh for the same target key is in | |
| 130 | + * flight. On failure, the previous successful snapshot is kept in `data` | |
| 131 | + * and the error message is stored separately so UI can keep rendering stale | |
| 132 | + * discovery results while showing the failure. | |
| 133 | + * | |
| 134 | + * @param target The logical runtime target to refresh. | |
| 135 | + * @param client Optional pre-resolved client, useful in tests. | |
| 136 | + */ | |
| 137 | + function refresh( | |
| 138 | + target: SourceControlDiscoveryTarget, | |
| 139 | + client?: SourceControlDiscoveryClient, | |
| 140 | + ): Promise<SourceControlDiscoveryResult | null> { | |
| 141 | + const targetKey = getSourceControlDiscoveryTargetKey(target); | |
| 142 | + if (targetKey === null) { | |
| 143 | + return Promise.resolve(null); | |
| 144 | + } | |
| 145 | + | |
| 146 | + const existing = refreshInFlight.get(targetKey); | |
| 147 | + if (existing) { | |
| 148 | + return existing; | |
| 149 | + } | |
| 150 | + | |
| 151 | + const resolvedClient = client ?? config.getClient(targetKey); | |
| 152 | + if (!resolvedClient) { | |
| 153 | + const error = new Error("Source control discovery client is unavailable."); | |
| 154 | + setError(targetKey, error); | |
| 155 | + return Promise.resolve(getSnapshot(target).data); | |
| 156 | + } | |
| 157 | + | |
| 158 | + markPending(targetKey); | |
| 159 | + const promise = resolvedClient.discoverSourceControl().then( | |
| 160 | + (result) => { | |
| 161 | + setData(targetKey, result); | |
| 162 | + return result; | |
| 163 | + }, | |
| 164 | + (error: unknown) => { | |
| 165 | + setError(targetKey, error); | |
| 166 | + return getSnapshot(target).data; | |
| 167 | + }, | |
| 168 | + ); | |
| 169 | + const tracked = promise.finally(() => refreshInFlight.delete(targetKey)); | |
| 170 | + refreshInFlight.set(targetKey, tracked); | |
| 171 | + return tracked; | |
| 172 | + } | |
| 173 | + | |
| 174 | + /** | |
| 175 | + * Read the current atom snapshot for `target`. | |
| 176 | + * | |
| 177 | + * Invalid targets return the inert empty state rather than creating a new | |
| 178 | + * family atom entry. | |
| 179 | + */ | |
| 180 | + function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { | |
| 181 | + const targetKey = getSourceControlDiscoveryTargetKey(target); | |
| 182 | + if (targetKey === null) { | |
| 183 | + return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; | |
| 184 | + } | |
| 185 | + | |
| 186 | + return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | |
| 187 | + } | |
| 188 | + | |
| 189 | + /** | |
| 190 | + * Clear in-flight refresh tracking and reset every known discovery atom. | |
| 191 | + * Primarily used by tests and runtime teardown. | |
| 192 | + */ | |
| 193 | + function reset(): void { | |
| 194 | + refreshInFlight.clear(); | |
| 195 | + for (const key of knownSourceControlDiscoveryKeys) { | |
| 196 | + setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); | |
| 197 | + } | |
| 198 | + knownSourceControlDiscoveryKeys.clear(); | |
| 199 | + } | |
| 200 | + | |
| 201 | + return { | |
| 202 | + refresh, | |
| 203 | + getSnapshot, | |
| 204 | + reset, | |
| 205 | + }; | |
| 206 | +} | |
| @@ -46,6 +46,21 @@ export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryClo · 45 unmodified lines | ||
| 46 | 46 | export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); |
| 47 | 47 | export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; |
| 48 | 48 | |
| 49 | +export const SourceControlProviderAuthStatus = Schema.Literals([ | |
| 50 | + "authenticated", | |
| 51 | + "unauthenticated", | |
| 52 | + "unknown", | |
| 53 | +]); | |
| 54 | +export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type; | |
| 55 | + | |
| 56 | +export const SourceControlProviderAuth = Schema.Struct({ | |
| 57 | + status: SourceControlProviderAuthStatus, | |
| 58 | + account: Schema.Option(TrimmedNonEmptyString), | |
| 59 | + host: Schema.Option(TrimmedNonEmptyString), | |
| 60 | + detail: Schema.Option(TrimmedNonEmptyString), | |
| 61 | +}); | |
| 62 | +export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; | |
| 63 | + | |
| 49 | 64 | const SourceControlDiscoveryItemFields = { |
| 50 | 65 | label: TrimmedNonEmptyString, |
| 51 | 66 | executable: TrimmedNonEmptyString, |
| @@ -71,6 +86,7 @@ export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; · 19 unmodified lines | ||
| 71 | 86 | export const SourceControlProviderDiscoveryItem = Schema.Struct({ |
| 72 | 87 | kind: SourceControlProviderKind, |
| 73 | 88 | ...SourceControlDiscoveryItemFields, |
| 89 | + auth: SourceControlProviderAuth, | |
| 74 | 90 | }); |
| 75 | 91 | export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; |
| 76 | 92 | |