diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4444a7851dffca7c862ed945342d6ef486895ba0..d29db34e06647cccf5e32dbafa7e870619e36186 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -26,6 +26,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; @@ -1418,7 +1419,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); it.effect( - "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + "keeps source control host secrets in the secret store and tells clients only that one is set", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1427,139 +1428,134 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; const saved = yield* serverSettings.updateSettings({ - bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + sourceControlHosts: { + bitbucket: { email: "me@example.com", accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); - assert.deepEqual(saved.bitbucket, { + assert.deepEqual(saved.sourceControlHosts.bitbucket, { email: "me@example.com", accessToken: "bb-access", - apiToken: "bb-api", }); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "bb-access"); - assert.notInclude(raw, "bb-api"); + for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret); assert.include(raw, "me@example.com"); - const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; - assert.equal(forClient.email, "me@example.com"); - assert.notInclude(forClient.accessToken, "bb-access"); - assert.notInclude(forClient.apiToken, "bb-api"); - assert.isAbove(forClient.accessToken.length, 0); - assert.isAbove(forClient.apiToken.length, 0); - - // A client echoing the redacted values back, or omitting them, keeps the saved tokens. - yield* serverSettings.updateSettings({ bitbucket: forClient }); - yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); - assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { - email: "other@example.com", - accessToken: "bb-access", - apiToken: "bb-api", - }); - - const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); - assert.equal(cleared.bitbucket.accessToken, ""); - assert.equal(cleared.bitbucket.apiToken, "bb-api"); - assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); - assert.equal( - ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, - "", - ); - }).pipe(Effect.provide(layerServerSettingsWithSecrets())), - ); - - it.effect( - "keeps GitHub tokens per host in the secret store and tells clients only that one is set", - () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const secrets = yield* ServerSecretStore.ServerSecretStore; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - - const saved = yield* serverSettings.updateSettings({ - github: { tokens: { "GitHub.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, - }); - assert.deepEqual(saved.github.tokens, { - "github.com": "ghp_dotcom", - "ghe.acme.test": "ghp_ghe", - }); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "ghp_dotcom"); - assert.notInclude(raw, "ghp_ghe"); - - const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).github; - assert.notInclude(forClient.tokens["github.com"]!, "ghp_dotcom"); - assert.isAbove(forClient.tokens["github.com"]!.length, 0); - - // Echoing the redacted values back keeps them; host and account changes leave tokens alone. - yield* serverSettings.updateSettings({ github: { tokens: forClient.tokens } }); + const forClient = + ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts; + assert.equal(forClient.bitbucket?.email, "me@example.com"); + assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access"); + assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0); + + // Echoing the redacted values back keeps them; other fields change on their own. + yield* serverSettings.updateSettings({ sourceControlHosts: forClient }); yield* serverSettings.updateSettings({ - github: { hosts: { "github.com": { enabled: true, account: "work" } } }, + sourceControlHosts: { bitbucket: { email: "other@example.com" } }, }); - assert.deepEqual((yield* serverSettings.getSettings).github.tokens, { + const kept = (yield* serverSettings.getSettings).sourceControlHosts; + assert.deepEqual(kept.bitbucket, { email: "other@example.com", accessToken: "bb-access" }); + assert.deepEqual(kept.github?.tokens, { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe", }); - // An empty token removes that host's token and nothing else. + // An empty value removes that secret, and one per server host leaves the others. const cleared = yield* serverSettings.updateSettings({ - github: { tokens: { "github.com": "" } }, + sourceControlHosts: { + bitbucket: { accessToken: "" }, + github: { + tokens: { + "github.com": "", + "ghe.acme.test": (forClient.github!.tokens as Record)[ + "ghe.acme.test" + ], + }, + }, + }, }); - assert.equal(cleared.github.tokens["github.com"] ?? "", ""); - assert.equal(cleared.github.tokens["ghe.acme.test"], "ghp_ghe"); - const remaining = yield* Effect.forEach(["github.com", "ghe.acme.test"], (host) => - secrets.get(`github-token-${Buffer.from(host, "utf8").toString("base64url")}`), - ); - assert.isTrue(Option.isNone(remaining[0]!)); - assert.isTrue(Option.isSome(remaining[1]!)); + assert.equal(cleared.sourceControlHosts.bitbucket?.accessToken, ""); + const stored = (kind: string, field: string, serverHost: string | null) => + secrets.get(sourceControlHostSecretName({ kind, field, serverHost })); + assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); - it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => + it.effect("keeps the retired keys for another load when a token cannot be copied", () => { + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "bitbucket-access-token", + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "bitbucket-access-token", + description: "Secret backend unavailable.", + }), + }); + const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(layerFailingSecretStore(cause)), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-legacy-failure-test-", + }), + ), + ), + ); + return Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const legacy = JSON.stringify({ + bitbucket: { email: "me@example.com", accessToken: "\u2022\u2022\u2022\u2022\u2022\u2022" }, + }); + yield* fileSystem.writeFileString(serverConfig.settingsPath, legacy); + + yield* Effect.exit(serverSettings.getSettings); + + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy); + }).pipe(Effect.provide(layerSettings)); + }); + + it.effect("moves the retired bitbucket and github keys and their secrets on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const secrets = yield* ServerSecretStore.ServerSecretStore; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - // A token was saved, then the user deleted it from settings.json directly. - yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); - yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + const marker = "\u2022\u2022\u2022\u2022\u2022\u2022"; + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("bb-saved")); + yield* secrets.set( + `github-token-${Buffer.from("github.com", "utf8").toString("base64url")}`, + new TextEncoder().encode("ghp_saved"), + ); + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + JSON.stringify({ + bitbucket: { email: "me@example.com", accessToken: marker }, + github: { + hosts: { "github.com": { account: "work" } }, + tokens: { "github.com": marker }, + }, + }), + ); - yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + const loaded = (yield* serverSettings.getSettings).sourceControlHosts; + assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" }); + assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" }); + assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, '"bitbucket":{"email"'); + assert.include(raw, "sourceControlHosts"); assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); - it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const secrets = yield* ServerSecretStore.ServerSecretStore; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - '{"bitbucket":{"accessToken":"hand-edited-token"}}', - ); - - // Loading alone moves it: no settings update is needed. - const loaded = yield* serverSettings.getSettings; - - assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); - assert.notInclude( - yield* fileSystem.readFileString(serverConfig.settingsPath), - "hand-edited-token", - ); - const stored = yield* secrets.get("bitbucket-access-token"); - assert.equal( - Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, - "hand-edited-token", - ); - }).pipe(Effect.provide(layerServerSettingsWithSecrets())), - ); - it.effect( - "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + "moves a hand-edited source control token into the secret store when settings load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1567,24 +1563,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + '{"sourceControlHosts":{"bitbucket":{"accessToken":"hand-edited-token"}}}', ); - // The form resends the redacted token when only the email changes. - const forClient = ServerSettingsModule.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ).bitbucket; - const updated = yield* serverSettings.updateSettings({ - bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, - }); + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; - assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); - assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.equal(loaded.sourceControlHosts.bitbucket?.accessToken, "hand-edited-token"); assert.notInclude( yield* fileSystem.readFileString(serverConfig.settingsPath), "hand-edited-token", ); - }).pipe(Effect.provide(layerServerSettings())), + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); it.effect("materializes provider secrets for terminal environment resolution", () => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 7536a74d6e02cc56f1123eedec70c472a10731db..5a36f77e4220bf3e7d8859e763e63d698901887b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -64,6 +64,13 @@ import { isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import { + readSecretFieldValues, + SOURCE_CONTROL_HOST_SECRET_FIELDS, + type SourceControlHostSecretSlot, + sourceControlHostSecretName, + writeSecretFieldValues, +} from "./sourceControl/sourceControlHostSecrets.ts"; export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; @@ -157,19 +164,48 @@ function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } -const BITBUCKET_SECRET_NAMES = { - accessToken: "bitbucket-access-token", - apiToken: "bitbucket-api-token", -} as const; -const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; - -/** Hosts are case-insensitive; a patch can arrive before decoding lowercased its keys. */ -function gitHubTokenSecretName(host: string): string { - return `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`; -} - const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); +/** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */ +function mapSlotValues( + hosts: ServerSettings["sourceControlHosts"], + map: (slot: SourceControlHostSecretSlot, value: string) => string, +): ServerSettings["sourceControlHosts"] { + return Object.fromEntries( + Object.entries(hosts).map(([kind, fields]) => { + const secretFields = SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []; + if (secretFields.length === 0) return [kind, fields]; + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + next[field] = writeSecretFieldValues( + next[field], + readSecretFieldValues(next[field]).map( + ([serverHost, value]) => [serverHost, map({ kind, field, serverHost }, value)] as const, + ), + ); + } + return [kind, next]; + }), + ); +} + +/** Every saved secret slot in `hosts`, with its current value. */ +function sourceControlHostSecretSlots( + hosts: ServerSettings["sourceControlHosts"], +): ReadonlyArray { + return Object.entries(hosts).flatMap(([kind, fields]) => + (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => + readSecretFieldValues(fields[field]).map(([serverHost, value]) => ({ + kind, + field, + serverHost, + value, + })), + ), + ); +} + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -206,18 +242,10 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS }, ]), ); - const bitbucket = { - ...settings.bitbucket, - accessToken: redactSecret(settings.bitbucket.accessToken), - apiToken: redactSecret(settings.bitbucket.apiToken), - }; - const github = { - ...settings.github, - tokens: Object.fromEntries( - Object.entries(settings.github.tokens).map(([host, token]) => [host, redactSecret(token)]), - ), - }; - return { ...settings, providerInstances, usageLimitSources, bitbucket, github }; + const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) => + redactSecret(value), + ); + return { ...settings, providerInstances, usageLimitSources, sourceControlHosts }; } export function applyProviderInstanceMutation( @@ -394,10 +422,60 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + // Retired for `sourceControlHosts`; read only by `migrateLegacySourceControlSettings`. + bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), }), ); const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); +/** The secret store names the retired `bitbucket` and `github` keys kept their tokens under. */ +const LEGACY_BITBUCKET_SECRET_NAMES: Readonly> = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +}; +const legacyGitHubTokenSecretName = (host: string) => + `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`; + +/** + * Folds the retired `bitbucket` and `github` keys into `sourceControlHosts`, unless the new key + * already holds that host. Returns the secret store entries to copy to their new names. + */ +function migrateLegacySourceControlSettings( + settings: ServerSettings, + legacy: { + readonly bitbucket?: Readonly> | undefined; + readonly github?: Readonly> | undefined; + }, +): { + readonly settings: ServerSettings; + readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>; +} { + const sourceControlHosts = { ...settings.sourceControlHosts }; + const secretMoves: Array<{ readonly from: string; readonly to: string }> = []; + if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) { + sourceControlHosts.bitbucket = legacy.bitbucket; + for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) { + if (legacy.bitbucket[field] !== SECRET_REDACTED) continue; + secretMoves.push({ + from, + to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }), + }); + } + } + if (legacy.github !== undefined && sourceControlHosts.github === undefined) { + sourceControlHosts.github = legacy.github; + for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) { + if (serverHost === null || value !== SECRET_REDACTED) continue; + secretMoves.push({ + from: legacyGitHubTokenSecretName(serverHost), + to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }), + }); + } + } + return { settings: { ...settings, sourceControlHosts }, secretMoves }; +} + // Drivers that start disabled, so a session in the history means the user // turned them on before the instance kept an explicit flag. const HISTORY_RESTORED_DRIVERS: ReadonlySet = new Set( @@ -724,54 +802,41 @@ const make = Effect.gen(function* () { ); /** - * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, - * so plaintext does not stay on disk. If the store is unavailable, the token keeps working - * from the file and the move is retried on the next load. + * Moves source control host secrets hand-edited into settings.json into the secret store as + * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps + * working from the file and the move is retried on the next load. */ - const moveInlineBitbucketTokens = (settings: ServerSettings) => + const moveInlineSourceControlHostSecrets = (settings: ServerSettings) => Effect.gen(function* () { - const bitbucket = { ...settings.bitbucket }; - let moved = false; - for (const field of BITBUCKET_SECRET_FIELDS) { - const value = bitbucket[field]; - if (value.length === 0 || value === SECRET_REDACTED) continue; + const moved = new Set(); + for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { + if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue; const stored = yield* secretStore - .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value)) .pipe( Effect.as(true), Effect.catch(() => - Effect.logWarning("failed to move a Bitbucket token into the secret store", { - field, + Effect.logWarning("failed to move a source control secret into the secret store", { + kind: slot.kind, + field: slot.field, }).pipe(Effect.as(false)), ), ); - if (!stored) continue; - bitbucket[field] = SECRET_REDACTED; - moved = true; + if (stored) moved.add(sourceControlHostSecretName(slot)); } - const tokens = { ...settings.github.tokens }; - for (const [host, value] of Object.entries(tokens)) { - if (value.length === 0 || value === SECRET_REDACTED) continue; - const stored = yield* secretStore - .set(gitHubTokenSecretName(host), textEncoder.encode(value)) - .pipe( - Effect.as(true), - Effect.catch(() => - Effect.logWarning("failed to move a GitHub token into the secret store", { - host, - }).pipe(Effect.as(false)), - ), - ); - if (!stored) continue; - tokens[host] = SECRET_REDACTED; - moved = true; - } - return moved ? { ...settings, bitbucket, github: { ...settings.github, tokens } } : settings; + if (moved.size === 0) return settings; + return { + ...settings, + sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => + moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value, + ), + }; }); const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let legacyProviders: Readonly> | undefined; + let legacySourceControl: Parameters[1] = {}; // A file that failed to decode must stay on disk for the user to repair; // the fold below only writes when it started from the file's real contents. let settingsFileTrusted = true; @@ -782,6 +847,10 @@ const make = Effect.gen(function* () { const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { legacyProviders = legacySettings.value.providers; + legacySourceControl = { + bitbucket: legacySettings.value.bitbucket, + github: legacySettings.value.github, + }; } if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; @@ -855,12 +924,48 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; + // Copied, not moved: the old entry is removed only after the new file is written. A token + // that cannot be read or copied leaves the retired keys in place, so the migration runs again + // on the next load instead of dropping it. + const legacySecretsCopied = yield* Effect.forEach( + settingsFileTrusted + ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves + : [], + (move) => + secretStore.get(move.from).pipe( + Effect.flatMap((secret) => + Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value), + ), + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to copy a source control secret to its new name", { + secretName: move.to, + }).pipe(Effect.as(false)), + ), + ), + ).pipe(Effect.map((copied) => copied.every(Boolean))); + const hasLegacySourceControl = + settingsFileTrusted && + legacySecretsCopied && + (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined); + const sourceControlMigration = hasLegacySourceControl + ? migrateLegacySourceControlSettings(folded, legacySourceControl) + : { settings: folded, secretMoves: [] }; // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. - const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; - // A file still carrying the retired `providers` map is rewritten once so - // the migrated instances persist and the old key disappears. - if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) { + const migrated = settingsFileTrusted + ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings) + : folded; + // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten + // once so the migrated values persist and the old keys disappear. + if ( + migrated !== loaded || + (settingsFileTrusted && legacyProviders !== undefined) || + hasLegacySourceControl + ) { yield* writeSettingsAtomically(migrated); + for (const move of sourceControlMigration.secretMoves) { + yield* secretStore.remove(move.from).pipe(Effect.ignore); + } } return migrated; }); @@ -933,39 +1038,30 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } - const bitbucket = { ...settings.bitbucket }; - for (const field of BITBUCKET_SECRET_FIELDS) { - if (bitbucket[field] !== SECRET_REDACTED) continue; + const storedSecrets = new Map(); + for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { + if (slot.value !== SECRET_REDACTED) continue; const secret = yield* secretStore - .get(BITBUCKET_SECRET_NAMES[field]) + .get(sourceControlHostSecretName(slot)) .pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), ), ); - bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; - } - const tokens: Record = {}; - for (const [host, value] of Object.entries(settings.github.tokens)) { - if (value !== SECRET_REDACTED) { - tokens[host] = value; - continue; - } - const secret = yield* secretStore - .get(gitHubTokenSecretName(host)) - .pipe( - Effect.mapError( - (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), - ), - ); - tokens[host] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + storedSecrets.set( + sourceControlHostSecretName(slot), + Option.isSome(secret) ? textDecoder.decode(secret.value) : "", + ); } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], - bitbucket, - github: { ...settings.github, tokens }, + sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => + value === SECRET_REDACTED + ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "") + : value, + ), }; }); @@ -1107,56 +1203,36 @@ const make = Effect.gen(function* () { }); } - const bitbucket = { ...next.bitbucket }; - for (const field of BITBUCKET_SECRET_FIELDS) { - let value = bitbucket[field]; - if (value === SECRET_REDACTED) { - // The marker keeps what is saved. A plaintext value hand-edited into settings.json - // is not in the secret store yet, so move it there instead of dropping it. - const inline = current.bitbucket[field]; - if (inline === SECRET_REDACTED || inline.length === 0) continue; - value = inline; - } - const secretName = BITBUCKET_SECRET_NAMES[field]; - if (value.length === 0) { - changes.push({ kind: "remove", secretName, operation: "remove-secret" }); - continue; - } - changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); - bitbucket[field] = SECRET_REDACTED; - } - - const tokens: Record = {}; - for (const [rawHost, raw] of Object.entries(next.github.tokens)) { - const host = rawHost.trim().toLowerCase(); - let value = raw; - if (value === SECRET_REDACTED) { - // The marker keeps what is saved; a hand-edited plaintext token moves into the store. - const inline = current.github.tokens[host]; - if (inline === undefined || inline === SECRET_REDACTED || inline.length === 0) { - tokens[host] = SECRET_REDACTED; - continue; - } - value = inline; - } - const secretName = gitHubTokenSecretName(host); - if (value.length === 0) { - changes.push({ kind: "remove", secretName, operation: "remove-secret" }); - continue; - } - changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); - tokens[host] = SECRET_REDACTED; - } - const nextHosts = new Set( - Object.keys(next.github.tokens).map((host) => host.trim().toLowerCase()), + // Hand-edited plaintext not yet in the store, so the redaction marker can move it there. + const inlineSecrets = new Map( + sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) => + slot.value.length > 0 && slot.value !== SECRET_REDACTED + ? [[sourceControlHostSecretName(slot), slot.value] as const] + : [], + ), ); - for (const host of Object.keys(current.github.tokens)) { - if (nextHosts.has(host.trim().toLowerCase())) continue; - changes.push({ - kind: "remove", - secretName: gitHubTokenSecretName(host), - operation: "remove-stale-secret", - }); + const nextSecretNames = new Set(); + const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => { + const secretName = sourceControlHostSecretName(slot); + // The marker keeps what is saved; a hand-edited plaintext value moves into the store. + const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw; + if (value === undefined) { + nextSecretNames.add(secretName); + return SECRET_REDACTED; + } + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + return ""; + } + nextSecretNames.add(secretName); + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + return SECRET_REDACTED; + }); + for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) { + const secretName = sourceControlHostSecretName(slot); + if (nextSecretNames.has(secretName)) continue; + if (changes.some((change) => change.secretName === secretName)) continue; + changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" }); } return { @@ -1164,8 +1240,7 @@ const make = Effect.gen(function* () { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], - bitbucket, - github: { ...next.github, tokens }, + sourceControlHosts, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index f879d57665bac04c0aed397d5211a2606464a38b..22abb952990b16687301975d4cd3969793d22ec1 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -632,16 +632,22 @@ it.effect("prefers credentials saved in settings over the environment, without a assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); yield* settings.updateSettings({ - bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + sourceControlHosts: { + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }, }); yield* bitbucket.probeAuth; assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); - yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, + }); yield* bitbucket.probeAuth; assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); - yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "", apiToken: "" } }, + }); yield* bitbucket.probeAuth; assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); }).pipe(Effect.provide(layer)); @@ -658,7 +664,9 @@ it.effect("never puts a saved token that is unsafe for an HTTP header on the wir // Fetch would reject this header with an error quoting the token, and that error reaches // clients. The unusable token is ignored, so the environment credential is used instead. - yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "saved\ntoken" } }, + }); yield* bitbucket.probeAuth; assert.strictEqual( execute.mock.calls.at(-1)?.[0].headers.authorization, @@ -679,7 +687,9 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); - yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, + }); assert.deepStrictEqual(yield* bitbucket.probeAuth, { status: "unknown", account: Option.none(), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index ff3933a98ed377d063137a9d720dc4e547d152c5..cc79c19571ea9bf58acb17f2aad91009a2d526e1 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -27,6 +27,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -56,6 +57,9 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(GitCafeApi.GitCafeApi)({}), + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + cliEnv: () => Effect.succeed({}), + }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index d174ed19eeab532e74a2db86a1a73a8055503547..2ff7fdb0d702052cac931c8e5aac2f1e393333c4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -26,6 +26,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -129,6 +130,9 @@ function makeRegistry(input: { Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(GitCafeApi.GitCafeApi)({}), + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + cliEnv: () => Effect.succeed({}), + }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts new file mode 100644 index 0000000000000000000000000000000000000000..7790c1dbc033490edf4f799ad7896f0c4e74af08 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -0,0 +1,76 @@ +/** + * Which source control host settings fields hold secrets, read from each built-in host's client + * definition. `serverSettings` keeps those values in the secret store and redacts them for + * clients, the same way for every host. + * + * A secret field holds a string, or a string per server host such as GitHub's `tokens`. + * + * @module sourceControl/sourceControlHostSecrets + */ +import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; +import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; +import { + secretSourceControlHostSettingsFields, + type SourceControlClientDefinition, +} from "@t3tools/source-control-core/client/definition"; +import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; +import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; +import * as GitHub from "@t3tools/source-control-github/client/definition"; +import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; + +const definitions: ReadonlyArray = [ + GitHub.definition, + GitLab.definition, + AzureDevOps.definition, + Bitbucket.definition, + Forgejo.definition, + GitCafe.definition, +]; + +/** Secret field names per host kind; a host with none is absent. */ +export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< + string, + ReadonlyArray +> = new Map( + definitions.flatMap((definition) => { + const fields = definition.settings + ? secretSourceControlHostSettingsFields(definition.settings) + : []; + return fields.length > 0 ? [[definition.kind, fields] as const] : []; + }), +); + +/** One stored secret: a host's field, and the server host it is for when the field is per host. */ +export interface SourceControlHostSecretSlot { + readonly kind: string; + readonly field: string; + readonly serverHost: string | null; +} + +/** The secret store entry for one slot; names stay stable across releases. */ +export function sourceControlHostSecretName(slot: SourceControlHostSecretSlot): string { + const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); + return slot.serverHost === null + ? `source-control-${encode(slot.kind)}-${encode(slot.field)}` + : `source-control-${encode(slot.kind)}-${encode(slot.field)}-${encode(slot.serverHost.trim().toLowerCase())}`; +} + +/** The string values a secret field holds, keyed by server host, or `null` for a plain string. */ +export function readSecretFieldValues( + value: unknown, +): ReadonlyArray { + if (typeof value === "string") return [[null, value]]; + if (value === null || typeof value !== "object" || Array.isArray(value)) return []; + return Object.entries(value).flatMap(([serverHost, entry]) => + typeof entry === "string" ? [[serverHost.trim().toLowerCase(), entry] as const] : [], + ); +} + +/** A secret field's value rebuilt from per-slot values, in the shape it was read from. */ +export function writeSecretFieldValues( + original: unknown, + values: ReadonlyArray, +): unknown { + if (typeof original === "string") return values[0]?.[1] ?? ""; + return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); +} diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx deleted file mode 100644 index a423334d1c7f925c937ee16efd827f69604edd49..0000000000000000000000000000000000000000 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts"; -import { ExternalLinkIcon } from "lucide-react"; -import { useState } from "react"; - -import { useEnvironmentSettings } from "../../hooks/useSettings"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { Button, InlineButton } from "../ui/button"; -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; - -type CredentialMethod = "access-token" | "api-token"; - -const METHODS: Record< - CredentialMethod, - { - readonly label: string; - readonly description: string; - readonly link: string; - readonly linkLabel: string; - } -> = { - "access-token": { - label: "Access token", - description: - "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", - link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/", - linkLabel: "Learn more", - }, - "api-token": { - label: "API token", - description: - "Uses your Atlassian account, so it reaches every repository you can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", - link: "https://id.atlassian.com/manage-profile/security/api-tokens", - linkLabel: "Create an API token", - }, -}; - -function savedMethod(saved: BitbucketSettings): CredentialMethod | null { - if (saved.accessToken.length > 0) return "access-token"; - if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; - return null; -} - -/** A write-only token field. It never shows the saved token; typing a new one replaces it. */ -function TokenInput({ - id, - isSaved, - draft, - onDraftChange, -}: { - readonly id: string; - readonly isSaved: boolean; - readonly draft: string; - readonly onDraftChange: (draft: string) => void; -}) { - return ( - onDraftChange(event.target.value)} - /> - ); -} - -/** - * Bitbucket credentials for one environment: an access token or an Atlassian - * account email + API token, never both. Tokens are write-only: the server - * keeps them in its secret store and only reports whether each one is set. - */ -export function BitbucketCredentialsSettings({ - environmentId, - onSaved, -}: { - readonly environmentId: EnvironmentId; - readonly onSaved: () => void; -}) { - const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket); - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "save Bitbucket credentials", - }); - const [methodChoice, setMethodChoice] = useState(null); - const [accessToken, setAccessToken] = useState(""); - const [emailDraft, setEmailDraft] = useState(null); - const [apiToken, setApiToken] = useState(""); - const [saving, setSaving] = useState(false); - const current = savedMethod(saved); - const method = methodChoice ?? current ?? "access-token"; - const methodIsSaved = current === method; - const email = (emailDraft ?? saved.email).trim(); - const newAccessToken = accessToken.trim(); - const newApiToken = apiToken.trim(); - const info = METHODS[method]; - - // Saving one method clears the other, so a hidden credential never wins over the visible one. - const patch: BitbucketSettings | null = - method === "access-token" - ? newAccessToken - ? { accessToken: newAccessToken, email: "", apiToken: "" } - : null - : email && (newApiToken || saved.apiToken) - ? // Resending the saved token's redacted value keeps it. - { accessToken: "", email, apiToken: newApiToken || saved.apiToken } - : null; - const canSave = - patch !== null && - (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email); - - const save = async (next: BitbucketSettings) => { - setSaving(true); - try { - const result = await updateSettings({ - environmentId, - input: { patch: { bitbucket: next } }, - }); - if (result._tag === "Success") { - setAccessToken(""); - setApiToken(""); - setEmailDraft(null); - onSaved(); - } - } finally { - setSaving(false); - } - }; - - return ( -
{ - event.preventDefault(); - if (canSave && patch) void save(patch); - }} - > - {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */} -
- { - const value = next[0]; - if (value === "access-token" || value === "api-token") setMethodChoice(value); - }} - > - {METHODS["access-token"].label} - {METHODS["api-token"].label} - -

- {info.description}{" "} - }> - {info.linkLabel} - - -

- {method === "access-token" ? ( -
- - -
- ) : ( - <> -
- - setEmailDraft(event.target.value)} - /> -
-
- - -
- - )} -
-

- {current === null - ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables." - : methodIsSaved - ? null - : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`} -

-
- {current !== null ? ( - - ) : null} - -
-
-
-
- ); -} diff --git a/apps/web/src/components/settings/GitHubAccountSettings.logic.ts b/apps/web/src/components/settings/GitHubAccountSettings.logic.ts index 466372f7699d808d7339e2d7cd3e3db44bd44e2c..0a3655cc6bb716c36090c23c6d1ee2321e7bdbc1 100644 --- a/apps/web/src/components/settings/GitHubAccountSettings.logic.ts +++ b/apps/web/src/components/settings/GitHubAccountSettings.logic.ts @@ -1,4 +1,5 @@ -import type { GitHubSettings, SourceControlProviderAuth } from "@t3tools/contracts"; +import type { SourceControlProviderAuth } from "@t3tools/contracts"; +import type { GitHubSettings } from "@t3tools/client-runtime/source-control-clients"; export type GitHubDiscoveredAccount = NonNullable[number]; diff --git a/apps/web/src/components/settings/GitHubAccountSettings.tsx b/apps/web/src/components/settings/GitHubAccountSettings.tsx index 98e7289b260fe1c4d3bf540e91caa065a8c4d3f6..9ca6caa031efef91c07e901432e123b1427ebd0f 100644 --- a/apps/web/src/components/settings/GitHubAccountSettings.tsx +++ b/apps/web/src/components/settings/GitHubAccountSettings.tsx @@ -9,6 +9,7 @@ import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic"; +import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients"; import { redactedPlaceholder } from "./RedactedSensitiveText"; /** Sentinel select value for "follow gh's active login"; logins never contain spaces. */ @@ -49,7 +50,8 @@ export function GitHubAccountSettings({ readonly auth: SourceControlProviderAuth; readonly onSaved: () => void; }) { - const hosts = useEnvironmentSettings(environmentId, (settings) => settings.github.hosts); + const github = useEnvironmentSettings(environmentId, readGitHubSettings); + const hosts = github.hosts; const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "save GitHub account settings", }); @@ -65,7 +67,11 @@ export function GitHubAccountSettings({ try { const result = await updateSettings({ environmentId, - input: { patch: { github: { hosts: nextGitHubHosts(hosts, host, change) } } }, + input: { + patch: { + sourceControlHosts: { github: { hosts: nextGitHubHosts(hosts, host, change) } }, + }, + }, }); if (result._tag === "Success") onSaved(); } finally { diff --git a/apps/web/src/components/settings/GitHubTokenSettings.tsx b/apps/web/src/components/settings/GitHubTokenSettings.tsx index 80ad552f15b26f4eb6836e8518eab7fef5c1bf74..9516d448b73aa5dacdb3586dbec7704f673d7c02 100644 --- a/apps/web/src/components/settings/GitHubTokenSettings.tsx +++ b/apps/web/src/components/settings/GitHubTokenSettings.tsx @@ -3,6 +3,7 @@ import { ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; @@ -28,7 +29,7 @@ export function GitHubTokenSettings({ readonly environmentId: EnvironmentId; readonly onSaved: () => void; }) { - const tokens = useEnvironmentSettings(environmentId, (settings) => settings.github.tokens); + const { tokens } = useEnvironmentSettings(environmentId, readGitHubSettings); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "save GitHub token", }); @@ -46,7 +47,10 @@ export function GitHubTokenSettings({ try { const result = await updateSettings({ environmentId, - input: { patch: { github: { tokens: { [target]: token } } } }, + // The map travels whole: every other saved token as its redaction marker, which keeps it. + input: { + patch: { sourceControlHosts: { github: { tokens: { ...tokens, [target]: token } } } }, + }, }); if (result._tag === "Success") { setDraft(""); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index d772585c74f30006be65fdfacb270fab0090399d..3671f72912ce922e3071304ab603d529497e7386 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -19,6 +19,11 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; + +/** What the form reads from a definition: an agent provider's, or a source control host's. */ +export type SettingsFormDefinition = Pick & { + readonly driverKind?: ProviderClientDefinition["driverKind"]; +}; import { SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { @@ -28,6 +33,8 @@ export interface ProviderSettingsFieldModel { readonly description?: string | undefined; readonly placeholder?: string | undefined; readonly clearWhenEmpty: "omit" | "persist"; + /** Kept in the server's secret store: a saved value arrives as `SECRET_REDACTED` and never shows. */ + readonly secret?: boolean | undefined; readonly defaultBooleanValue?: boolean | undefined; /** Choices for a `select` control. The first entry is the default. */ readonly options?: ReadonlyArray | undefined; @@ -63,7 +70,7 @@ function readProviderSettingsFormAnnotation( } function readProviderSettingsFormSchemaAnnotation( - definition: ProviderClientDefinition, + definition: SettingsFormDefinition, ): ProviderSettingsFormSchemaAnnotation { return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {}; } @@ -77,7 +84,7 @@ function readFieldBooleanDefault( } export function deriveProviderSettingsFields( - definition: ProviderClientDefinition, + definition: SettingsFormDefinition, value?: unknown, ): ReadonlyArray { const isLocalAcp = @@ -124,6 +131,7 @@ export function deriveProviderSettingsFields( ? { placeholder: formAnnotation.placeholder } : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", + ...(formAnnotation.secret ? { secret: true } : {}), ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), @@ -247,6 +255,19 @@ function readProviderConfigString(config: unknown, key: string): string { return typeof value === "string" ? value : ""; } +/** + * A secret field never shows its saved value: it starts empty, and its placeholder says whether + * one is saved. Saving one is typing a new value; clearing it is the host form's job. + */ +function secretFieldInputProps(field: ProviderSettingsFieldModel, config: unknown) { + const saved = readProviderConfigString(config, field.key); + if (!field.secret) return { value: saved, placeholder: field.placeholder }; + return { + value: "", + placeholder: saved.length > 0 ? "Stored secret, enter a new value to replace" : "Not set", + }; +} + function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; @@ -281,7 +302,7 @@ export function nextProviderConfigWithFieldValue( } interface ProviderSettingsFormProps { - readonly definition: ProviderClientDefinition; + readonly definition: SettingsFormDefinition; readonly value: unknown; readonly idPrefix: string; /** @@ -410,9 +431,12 @@ function ProviderSettingsFieldRow({ className="w-full max-w-full @min-[32rem]/settings-row:w-56" type={field.control === "password" ? "password" : undefined} autoComplete={field.control === "password" ? "off" : undefined} - value={readProviderConfigString(value, field.key)} - onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} - placeholder={field.placeholder} + {...secretFieldInputProps(field, value)} + onCommit={(next) => { + // An untouched saved secret commits empty; only a typed value replaces it. + if (field.secret && next === "") return; + onChange(nextProviderConfigWithFieldValue(value, field, next)); + }} spellCheck={false} /> ); diff --git a/apps/web/src/components/settings/SourceControlHostSettings.tsx b/apps/web/src/components/settings/SourceControlHostSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..353027c959581d8dc124b995bb4f7dbf7dd19354 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlHostSettings.tsx @@ -0,0 +1,84 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { SourceControlClientDefinition } from "@t3tools/client-runtime/source-control-clients"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { ProviderSettingsForm } from "./ProviderSettingsForm"; + +/** + * One host's settings, drawn from its definition's settings schema and saved under + * `sourceControlHosts[kind]`. Secret fields are write-only: the server keeps them in its secret + * store and only reports whether each one is set. + */ +export function SourceControlHostSettings({ + environmentId, + definition, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly definition: SourceControlClientDefinition & { + readonly settings: NonNullable; + }; + readonly onSaved: () => void; +}) { + const saved = useEnvironmentSettings( + environmentId, + (settings) => settings.sourceControlHosts[definition.kind], + ); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: `save ${definition.label} settings`, + }); + const [saving, setSaving] = useState(false); + const formDefinition = { settingsSchema: definition.settings }; + const hasSaved = Object.values(saved ?? {}).some((value) => value !== ""); + + const save = async (fields: Record) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { sourceControlHosts: { [definition.kind]: fields } } }, + }); + if (result._tag === "Success") onSaved(); + } finally { + setSaving(false); + } + }; + + return ( + // Locked while saving, so an edit made mid-request is not overwritten by the saved value. +
+ { + const fields = next ?? {}; + // Only what changed: each saved secret travels as its redaction marker anyway. + const changed = Object.fromEntries( + Object.entries(fields).filter(([key, value]) => saved?.[key] !== value), + ); + const cleared = Object.keys(saved ?? {}).filter((key) => !(key in fields)); + void save({ ...changed, ...Object.fromEntries(cleared.map((key) => [key, ""])) }); + }} + /> + {hasSaved ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index db7836054eeb12a453cc62e0325354910500de65..61414a01c2fc9e3f7e9f75a92cb91b4e580eab60 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -46,7 +46,7 @@ import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { GitIcon, JujutsuIcon, type Icon } from "../Icons"; -import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; +import { SourceControlHostSettings } from "./SourceControlHostSettings"; import { GitHubAccountSettings } from "./GitHubAccountSettings"; import { GitHubTokenSettings } from "./GitHubTokenSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; @@ -59,7 +59,7 @@ import { SettingsSection, useSettingsSearchTargetId, } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; +import { searchableSetting, sourceControlHostSettingsSearchId } from "./settingsSearch"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { sourceControlIcon } from "~/sourceControlPresentation"; @@ -278,8 +278,7 @@ function DiscoveryItemRow({ useEffect(() => { if ( (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || - (item.kind === "bitbucket" && - searchTargetId === searchableSetting("bitbucket-credentials").id) || + searchTargetId === sourceControlHostSettingsSearchId(item.kind) || (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id) ) { setIsExpanded(true); @@ -527,6 +526,22 @@ export function SourceControlSettingsPanel() { const handleScan = () => { discovery.refresh(); }; + /** The host's own settings form, for a host whose definition declares settings. */ + const hostSettingsPanel = (kind: string) => { + const definition = sourceControlClients.find(kind); + if (!definition?.settings || environmentId === null) return undefined; + return ( + + + + ); + }; const scanButton = ( {result.sourceControlProviders.map((item) => ( - {item.kind === "bitbucket" ? ( - - - - ) : item.kind === "github" ? ( + {item.kind === "github" ? (
{/* Shown even without gh: a saved token is how GitHub works without the CLI. */} @@ -619,7 +625,9 @@ export function SourceControlSettingsPanel() { ) : null}
- ) : undefined} + ) : ( + hostSettingsPanel(item.kind) + )}
))} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c23eeb8fdf061f4d764fd903e12d9e5fe6122fcc..ccb2d2a24a2a8da52618ba5388dcecc11579d858 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -4,6 +4,7 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { commandLabel } from "./KeybindingsSettings.logic"; import { @@ -132,6 +133,29 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right }; }); +/** Anchor id of a source control host's settings form on the Source Control page. */ +export function sourceControlHostSettingsSearchId(kind: string) { + return `source-control-host-${kind}` as const; +} + +/** One result per host whose definition declares settings, searchable by its field titles. */ +const SOURCE_CONTROL_HOST_SEARCH_ITEMS = sourceControlClients.definitions.flatMap((definition) => + definition.settings && definition.kind !== "github" + ? [ + { + id: sourceControlHostSettingsSearchId(definition.kind), + title: `${definition.label} credentials`, + to: "/settings/source-control" as const, + searchTerms: [ + `${definition.label} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`, + ], + environmentOnly: true, + scope: "environment-defaults" as const, + }, + ] + : [], +); + /** * Searchable settings and stable destinations, in result order. Rows with a * dedicated anchor render their id and title via `searchableSetting`; items @@ -808,14 +832,7 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, - { - id: "bitbucket-credentials", - title: "Bitbucket credentials", - to: "/settings/source-control", - searchTerms: ["bitbucket atlassian access token api token email credentials sign in"], - environmentOnly: true, - scope: "environment-defaults", - }, + ...SOURCE_CONTROL_HOST_SEARCH_ITEMS, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b0452974f1c1120a45be32ec28e70e36857d8824..741a90aa128c8664d31c53e3e6e2b8e093177594 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -64,18 +64,18 @@ glab auth login ### Bitbucket -Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: +Open **Settings → Source Control**, expand **Bitbucket**, and enter one of: - **Access token**: a token created for one repository, project, or workspace. It can only reach what it was created for. -- **API token**: an Atlassian API token for your account, used with your account email. It can +- **Atlassian account email** and **API token**: an Atlassian API token for your account. It can reach every repository you can. Give it read/write access to repositories and pull requests, plus user read access (`read:user:bitbucket`). -Choose **Save**; the change applies right away, and replaces any credential saved with the other -method. Credentials are saved on the environment's server, so select a remote environment to -configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose -**Remove**. +A field saves when you leave it, and applies right away. The access token wins when both are saved. +Credentials are saved on the environment's server, so select a remote environment to configure +it. Saved tokens can't be viewed again; enter a new one to replace it, or choose **Remove** to clear +everything saved for Bitbucket. If no credentials are saved, T3 Code falls back to these variables in the server's environment. Restart the server after changing them: @@ -105,8 +105,10 @@ bun install -g @gitcafe/cli cafe auth login --host https://git.cafe/api ``` -Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only -sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on +Alternatively, open **Settings → Source Control**, expand **GitCafe**, and enter a token. It is +kept in the server's secret store, applies right away, and is used before `CAFE_TOKEN` and the +`cafe` login. Or set `CAFE_TOKEN` in the server's environment and restart it. Both tokens are only +sent to `git.cafe`; `CAFE_TOKEN` follows `CAFE_HOST` if you set it. Repositories on `staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`. Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet. diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 52a2b2d4d2902e07cd0ca67833871f9c3add93f9..de68831cf58ba3653f40a36e7f42d8b96ccaabb8 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -9,7 +9,11 @@ */ import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; +import { + makeSourceControlClientRegistry, + readSourceControlHostSettings, +} from "@t3tools/source-control-core/client/definition"; +import type { ServerSettings } from "@t3tools/contracts"; export { UNKNOWN_SOURCE_CONTROL_CLIENT, @@ -40,3 +44,13 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, ); + +export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; + +/** + * An environment's GitHub host choices and tokens, decoded with GitHub's settings schema. GitHub's + * settings panels are its own, since its logins come from `gh` at runtime. + */ +export function readGitHubSettings(settings: Pick) { + return readSourceControlHostSettings(GitHub.settings, settings.sourceControlHosts.github); +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b9446389d08321b15a6911abb23e17105efbbf52..675344df4189487d078ab8df1a8c0f5204100f07 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -949,13 +949,6 @@ describe("ServerSettingsPatch.providerInstances", () => { }); describe("ServerSettingsPatch string normalization", () => { - it("lowercases GitHub hosts and defaults them to enabled", () => { - const patch = decodeServerSettingsPatch({ - github: { hosts: { " GitHub.com ": { account: " work " } } }, - }); - expect(patch.github?.hosts).toEqual({ "github.com": { account: "work", enabled: true } }); - }); - it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ addProjectBaseDirectory: " ~/Development ", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e5be96e740af0e9dfcfcc81fa3e3e0cb5885df4..a8f3a74eb0117283c00a4f940b40ae4c35b43c20 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -552,6 +552,11 @@ export interface ProviderSettingsFormAnnotation { readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; + /** + * The value is a credential: the server keeps it in its secret store and clients only learn + * whether one is saved. Source control host settings honor it; provider settings do not yet. + */ + readonly secret?: boolean | undefined; /** Choices for a `select` control. The first entry is the default. */ readonly options?: ReadonlyArray | undefined; } @@ -805,49 +810,20 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -/** - * Bitbucket API credentials for this environment, used before the - * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's - * secret store; settings and clients only see a redaction marker when one is - * set. The access token wins when both kinds are configured. - */ -export const BitbucketSettings = Schema.Struct({ - email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), -}); -export type BitbucketSettings = typeof BitbucketSettings.Type; - -/** - * Per-host choices for the GitHub CLI's logins. `account` pins one of the logins - * `gh` holds for the host instead of its active one; a disabled host gets no - * credential at all. A token saved here wins over `GH_TOKEN` and friends, which win over `gh`. - */ /** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */ export const GitHubHost = TrimmedNonEmptyString.pipe( Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), ); -export const GitHubHostSettings = Schema.Struct({ - account: Schema.optionalKey(TrimmedNonEmptyString), - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), -}); -export type GitHubHostSettings = typeof GitHubHostSettings.Type; - -export const GitHubSettings = Schema.Struct({ - /** Keyed by lowercased host, for example `github.com`. */ - hosts: Schema.Record(GitHubHost, GitHubHostSettings).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - ), - /** - * A token per host, used before `GH_TOKEN` and `gh`. The server keeps each one in its secret - * store; settings and clients only ever see a redaction marker for a saved token. - */ - tokens: Schema.Record(GitHubHost, TrimmedString).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - ), -}); -export type GitHubSettings = typeof GitHubSettings.Type; +/** + * One source control host's saved settings. Each host package declares the fields it reads in + * its client definition's settings schema and decodes this blob with it; the environment stores + * it whole, so a host this build does not ship keeps its settings. A field annotated as a secret + * holds a string, or a string per server host, whose value lives in the server's secret store: + * settings and clients only see a redaction marker for a saved one. + */ +export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); +export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1278,8 +1254,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + /** Keyed by source control host kind, such as `gitcafe`; see `SourceControlHostSettings`. */ + sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), // Keyed by a user-chosen id so a source keeps its rows across edits. Entries // this build cannot decode round-trip untouched, as provider instances do. usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( @@ -1523,24 +1501,12 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** An empty token clears it; an omitted one keeps what the server has. */ - bitbucket: Schema.optionalKey( - Schema.Struct({ - email: Schema.optionalKey(TrimmedString), - accessToken: Schema.optionalKey(TrimmedString), - apiToken: Schema.optionalKey(TrimmedString), - }), - ), /** - * `hosts` replaces the whole map, so an omitted host or account clears it. `tokens` merges per - * host: an empty token removes that host's token, the redaction marker keeps it. + * Merges per host: a patched field replaces the saved one, so a client sends a per-server-host + * map whole, with the redaction marker for each saved secret it keeps. An empty or omitted + * secret is removed from the secret store. */ - github: Schema.optionalKey( - Schema.Struct({ - hosts: Schema.optionalKey(Schema.Record(GitHubHost, GitHubHostSettings)), - tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), - }), - ), + sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index dbc2704171f962adc308371efd281ed4c1ad93e3..fe58c361d4a4465aad53d68e44349baad91c93c2 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -42,16 +42,19 @@ describe("serverSettings helpers", () => { logsAfterDays: 30, }); }); - it("replaces GitHub host choices so a cleared account pin does not survive", () => { + it("replaces a source control host's patched field so a cleared account pin does not survive", () => { const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - github: { hosts: { "github.com": { account: "work", enabled: true } } }, + sourceControlHosts: { + github: { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, + }, + }); + const unpinned = applyServerSettingsPatch(pinned, { + sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } }, + }); + expect(unpinned.sourceControlHosts.github).toEqual({ + hosts: { "github.com": { enabled: false } }, + tokens: {}, }); - expect(pinned.github.hosts).toEqual({ "github.com": { account: "work", enabled: true } }); - expect( - applyServerSettingsPatch(pinned, { - github: { hosts: { "github.com": { enabled: false } } }, - }).github.hosts, - ).toEqual({ "github.com": { enabled: false } }); }); it("replaces SSH host lists when saving, editing, and removing hosts", () => { const host = { id: "mini", label: "Mac mini", target: "mini" }; diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 909eeb0a35ce65720f4c23ba7d440e9f927a6b33..85af5bb1fdd6118ebd36864d7377c41bf25b2878 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -267,6 +267,7 @@ export function applyServerSettingsPatch( worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, + sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. @@ -367,9 +368,19 @@ export function applyServerSettingsPatch( ], } : {}), - // Host replacement: deepMerge would keep a cleared account pin. - ...(patch.github?.hosts !== undefined - ? { github: { ...next.github, hosts: patch.github.hosts } } + // Per host, a patched field replaces the saved one: deepMerge would keep a cleared account pin. + ...(sourceControlHostsPatch !== undefined + ? { + sourceControlHosts: { + ...current.sourceControlHosts, + ...Object.fromEntries( + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ + kind, + { ...current.sourceControlHosts[kind], ...fields }, + ]), + ), + }, + } : {}), ...(projectSettingsOverridesPatch !== undefined ? { diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 26f5c5d6f97e6f17579935f8335dbd46297ef253..fc0321ad81096c4d842645f37601601e667ad5e9 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -3,7 +3,13 @@ * * @module source-control-bitbucket/client/definition */ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + makeProviderSettingsSchema, + SourceControlProviderKind, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { defineSourceControlClient, isChangeRequestInProjectRepository, @@ -16,6 +22,41 @@ const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; const KIND = SourceControlProviderKind.make("bitbucket"); +const text = (annotations: Schema.Annotations.Key) => + TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey(annotations), + ); + +/** + * Bitbucket API credentials, used before the `T3CODE_BITBUCKET_*` environment variables: an + * access token, or an Atlassian account email with an API token. The access token wins when both + * are saved. + */ +export const settings = makeProviderSettingsSchema( + { + accessToken: text({ + title: "Access token", + description: + "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", + providerSettingsForm: { control: "password", secret: true }, + }), + email: text({ + title: "Atlassian account email", + description: "With an API token instead of an access token.", + providerSettingsForm: { placeholder: "you@example.com" }, + }), + apiToken: text({ + title: "API token", + description: + "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", + providerSettingsForm: { control: "password", secret: true }, + }), + }, + { order: ["accessToken", "email", "apiToken"] }, +); +export type BitbucketSettings = typeof settings.Type; + export const definition = defineSourceControlClient({ kind: KIND, label: "Bitbucket", @@ -54,4 +95,5 @@ export const definition = defineSourceControlClient({ canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), + settings, }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 3df66527baeeaf92676e12f4a507d705447d82ac..d9d68144f1fc58a149bcde5edfb8a9246cad9a11 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -7,10 +7,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, - type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -31,6 +29,9 @@ import { import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; + +import * as BitbucketClient from "../client/definition.ts"; import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; @@ -569,7 +570,7 @@ function credentialFrom(input: { * stay as a fallback. Within each source the access token wins. */ function resolveCredential( - settings: BitbucketSettings, + settings: BitbucketClient.BitbucketSettings, env: Config.Success, ): BitbucketCredential | null { return ( @@ -664,12 +665,24 @@ export const make = Effect.gen(function* () { // Read on every request so credentials saved in settings apply without a restart. const currentCredential = host.settings.get.pipe( - Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.map((settings) => + resolveCredential( + readSourceControlHostSettings( + BitbucketClient.settings, + settings.sourceControlHosts[BitbucketClient.definition.kind], + ), + config, + ), + ), Effect.catch((error) => // No cause: a settings decode error can quote a hand-edited token. Effect.logWarning("failed to read Bitbucket credentials from settings", { operation: error.operation, - }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + }).pipe( + Effect.as( + resolveCredential(readSourceControlHostSettings(BitbucketClient.settings, {}), config), + ), + ), ), ); diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 40d2f80783eee09ec090aa9ebfd053c9ed836e09..2f245844e467a4a8b949c279b1759ed39b6e5a31 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -19,6 +19,7 @@ import { SourceControlProviderKind, } from "@t3tools/contracts"; import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import * as Schema from "effect/Schema"; /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ export interface ChangeRequestTerminology { @@ -140,8 +141,20 @@ export interface SourceControlClientDefinition { readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ readonly isChangeRequestUrl: (url: string) => boolean; + /** + * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered + * as a form in Source Control settings and read by the host's server package. Fields carry + * `providerSettingsForm` annotations, as agent provider settings do; one marked `secret` is + * kept in the server's secret store. Omitted for a host with nothing to configure. + */ + readonly settings?: SourceControlHostSettingsSchema; } +/** A host's settings struct. Its fields are strings, or a secret string per server host. */ +export type SourceControlHostSettingsSchema = { + readonly fields: Readonly>; +} & Schema.Decoder; + /** * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number. * Matching the parsed path keeps a query or fragment that mentions another host's route out. @@ -293,3 +306,33 @@ export function makeSourceControlClientRegistry( definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", }; } + +/** + * A host's saved settings decoded with its schema. A blob that no longer decodes, such as one a + * newer build wrote, reads as the schema's defaults rather than failing the host. + */ +export function readSourceControlHostSettings( + schema: S, + saved: unknown, +): S["Type"] { + const decode = Schema.decodeUnknownOption(schema as Schema.Decoder); + const decoded = decode(saved ?? {}); + if (decoded._tag === "Some") return decoded.value; + const fallback = decode({}); + if (fallback._tag === "Some") return fallback.value; + throw new Error("A source control host settings schema must decode an empty object."); +} + +/** + * The settings fields a host keeps in the server's secret store: those annotated + * `providerSettingsForm.secret`. A secret field holds a string, or a string per server host. + */ +export function secretSourceControlHostSettingsFields( + schema: SourceControlHostSettingsSchema, +): ReadonlyArray { + return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => { + const annotations = + Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema); + return annotations?.providerSettingsForm?.secret ? [field] : []; + }); +} diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 202f297a9e17af1c6a9e5c207b3ddf66318105ff..59eb472648c2fe3b0dd09a62eaf415cad10776e2 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -3,7 +3,13 @@ * * @module source-control-gitcafe/client/definition */ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + makeProviderSettingsSchema, + SourceControlProviderKind, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { defineSourceControlClient, isChangeRequestInProjectRepository, @@ -16,6 +22,20 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); const KIND = SourceControlProviderKind.make("gitcafe"); +/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */ +export const settings = makeProviderSettingsSchema({ + token: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Token", + description: + "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.", + providerSettingsForm: { control: "password", secret: true }, + }), + ), +}); +export type GitCafeSettings = typeof settings.Type; + export const definition = defineSourceControlClient({ kind: KIND, label: "GitCafe", @@ -64,4 +84,5 @@ export const definition = defineSourceControlClient({ return false; } }, + settings, }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts index f0f8be74711f6d37e41104717b723b8606c9c34e..e54018f300884d8069d6abac41b20153e538e7d6 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -78,6 +78,44 @@ describe("GitCafeCredentials", () => { }).pipe(Effect.provide(layer)); }); + it.effect("uses a token saved in Settings for git.cafe only, ahead of CAFE_TOKEN", () => { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return Effect.succeed(processOutput("password=gct_staging\n")); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; + yield* settings.update({ sourceControlHosts: { gitcafe: { token: "saved-token" } } }); + + const saved = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(saved.token), "saved-token"); + assert.strictEqual(saved.source, "settings"); + assert.deepStrictEqual(yield* credentials.cliEnv("git.cafe"), { CAFE_TOKEN: "saved-token" }); + // Staging never receives the production token saved in Settings. + assert.deepStrictEqual(yield* credentials.cliEnv("staging.git.cafe"), {}); + assert.strictEqual( + Redacted.value((yield* credentials.get("staging.git.cafe")).token), + "gct_staging", + ); + + // Removing it applies at once: the environment token takes over again. + yield* settings.update({ sourceControlHosts: { gitcafe: { token: "" } } }); + assert.strictEqual((yield* credentials.get("git.cafe")).source, "env"); + }).pipe(Effect.provide(layer)); + }); + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { const { layer, calls } = harness(() => Effect.succeed( diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts index c47e9ce1153f37b0f1205d9a572596b040a5cfba..1aeb7b66e969fa12cf7e67b5dd2d2e799e222bae 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -10,6 +10,9 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; + +import * as GitCafeClient from "../client/definition.ts"; /** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ const TOKEN_TTL = Duration.minutes(5); @@ -19,7 +22,7 @@ const MISSING_TTL = Duration.seconds(10); export interface GitCafeCredential { readonly host: string; readonly token: Redacted.Redacted; - readonly source: "env" | "cafe"; + readonly source: "settings" | "env" | "cafe"; } /** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ @@ -66,10 +69,17 @@ export class GitCafeCredentials extends Context.Service< ) => Effect.Effect; /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ readonly invalidate: (host: string) => Effect.Effect; + /** + * Environment for a `cafe` run against the host: the token saved in Settings as `CAFE_TOKEN`, + * which `cafe` uses before its own login, or nothing when none is saved. + */ + readonly cliEnv: (host: string) => Effect.Effect>>; } >()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} const normalizeHost = (host: string) => host.trim().toLowerCase(); +/** The host `cafe` and a token saved in Settings target unless told otherwise. */ +const DEFAULT_HOST = "git.cafe"; /** * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by @@ -82,7 +92,7 @@ export function environmentToken( const configured = env.CAFE_HOST?.trim(); const configuredHost = configured ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host - : "git.cafe"; + : DEFAULT_HOST; if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; return env.CAFE_TOKEN?.trim() || null; } @@ -97,7 +107,8 @@ function credentialPassword(output: string): string | null { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const { process } = yield* SourceControlHost.SourceControlHost; + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const { process } = sourceControlHost; const environment = yield* HostProcess.Environment; const workingDirectory = yield* HostProcess.WorkingDirectory; @@ -134,14 +145,27 @@ export const make = Effect.gen(function* () { ), ); - const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + /** A token saved in Settings, for production git.cafe only; read fresh on every lookup. */ + const fromSettings = (host: string) => + normalizeHost(host) !== DEFAULT_HOST + ? Effect.succeed(null) + : sourceControlHost.settings.get.pipe( + Effect.map( + (settings) => + readSourceControlHostSettings( + GitCafeClient.settings, + settings.sourceControlHosts[GitCafeClient.definition.kind], + ).token || null, + ), + Effect.orElseSucceed(() => null), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* ( + host: string, + ): Effect.fn.Return { const fromEnv = environmentToken(host, environment); const token = fromEnv ?? (yield* fromCafe(host)); - return { - host, - token: Redacted.make(token), - source: fromEnv !== null ? "env" : "cafe", - } satisfies GitCafeCredential; + return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "cafe" }; }); const cache = yield* Cache.makeWith(lookup, { @@ -158,8 +182,24 @@ export const make = Effect.gen(function* () { }); return GitCafeCredentials.of({ - get: (host) => Cache.get(cache, normalizeHost(host)), + // A saved token is read fresh, so saving or removing one applies at once; the cache only + // spares asking `cafe` again. + get: (host) => + fromSettings(host).pipe( + Effect.flatMap( + (saved): Effect.Effect => + saved === null + ? Cache.get(cache, normalizeHost(host)) + : Effect.succeed({ + host: normalizeHost(host), + token: Redacted.make(saved), + source: "settings", + }), + ), + ), invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + cliEnv: (host) => + fromSettings(host).pipe(Effect.map((saved) => (saved === null ? {} : { CAFE_TOKEN: saved }))), }); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index 996925033eb3a62899277e6f242d098c7b5df760..565071b9e096fda03b78cb97a42c68fbdf7708c2 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -42,16 +42,8 @@ const failure = (code: string, status: number | null) => ({ }); describe("GitCafeSourceControlProvider", () => { - it("probes the cafe CLI against production", () => { - const { discovery } = GitCafeSourceControlProvider; - assert.strictEqual(discovery.kind, "gitcafe"); - assert.strictEqual(discovery.executable, "cafe"); - assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]); - assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]); - }); - it("reads a signed-in account and keeps transient failures apart from sign-out", () => { - const { parseAuth } = GitCafeSourceControlProvider.discovery; + const { parseAuth } = GitCafeSourceControlProvider; const signedIn = parseAuth( authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), ); @@ -115,7 +107,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge(TestSourceControlHost.layer()), Layer.provide( Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), @@ -162,7 +154,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ git: { @@ -244,7 +236,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ git: { @@ -304,7 +296,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ process: { diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index 5e271261f1e57e40edaee750bd681f5722ac4ab3..db4bea8dbf4c60fb2d4d95d6fa1e89b23a3e5d7a 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -11,13 +11,16 @@ import { type ChangeRequest, } from "@t3tools/contracts"; import { + probeSourceControlProvider, providerAuth, type SourceControlCliDiscoverySpec, + type SourceControlManagedCliDiscoverySpec, } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; import * as GitCafeHosts from "./gitCafeHosts.ts"; /** Discovery reports the production account; repository operations pick their own host. */ @@ -58,7 +61,7 @@ const cliFailure = (output: string) => .map((line) => decodeFailure(line.trim())) .find(Result.isSuccess)?.success.error; -export const discovery = { +const cliDiscovery = { type: "cli", kind: SourceControlProviderKind.make("gitcafe"), label: "GitCafe", @@ -90,9 +93,35 @@ export const discovery = { detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`, }); }, - installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, or set CAFE_TOKEN on the server.`, + installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, save a token in Settings, or set CAFE_TOKEN on the server.`, } satisfies SourceControlCliDiscoverySpec; +/** `cafe auth status`'s reading, exposed for tests. */ +export const parseAuth = cliDiscovery.parseAuth; + +/** Probes `cafe` as the CLI spec does, with a token saved in Settings handed to it. */ +export const makeDiscovery = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + return { + type: "managed-cli", + kind: cliDiscovery.kind, + label: cliDiscovery.label, + installHint: cliDiscovery.installHint, + probe: (cwd: string) => + credentials.cliEnv(AUTH_HOST).pipe( + Effect.flatMap((env) => + probeSourceControlProvider({ + cwd, + spec: cliDiscovery, + process: { run: (input) => process.run({ ...input, env: { ...input.env, ...env } }) }, + }), + ), + ), + refineUnknownRemote: () => Effect.succeed(null), + } satisfies SourceControlManagedCliDiscoverySpec; +}); + const Repository = Schema.Struct({ name: TrimmedNonEmptyString, defaultBranch: Schema.NullOr(TrimmedNonEmptyString), @@ -148,6 +177,7 @@ const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[? export const make = Effect.gen(function* () { const api = yield* GitCafeApi.GitCafeApi; const host = yield* SourceControlHost.SourceControlHost; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; const error = (operation: string, cwd: string, detail: string, cause?: unknown) => new SourceControlProviderError({ @@ -174,15 +204,20 @@ export const make = Effect.gen(function* () { ), ); const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => - host.process - .run({ - operation: `GitCafeSourceControlProvider.${operation}`, - command: "cafe", - cwd, - args: [...cliArgs(target.host), ...args], - env: { CAFE_OUTPUT: "json" }, - timeoutMs: 30_000, - }) + credentials + .cliEnv(target.host) + .pipe( + Effect.flatMap((env) => + host.process.run({ + operation: `GitCafeSourceControlProvider.${operation}`, + command: "cafe", + cwd, + args: [...cliArgs(target.host), ...args], + env: { CAFE_OUTPUT: "json", ...env }, + timeoutMs: 30_000, + }), + ), + ) .pipe( Effect.mapError((cause) => error( diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 6fc49c393f21a72900e57013aaa57bc177c6f8e5..2654f18b275ef2252883f5e49a9b2980e71bc8f4 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -15,7 +15,7 @@ export const driver = defineSourceControlDriver({ kind: SourceControlProviderKind.make("gitcafe"), make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, - discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + discovery: GitCafeSourceControlProvider.makeDiscovery, pullRequests: GitCafePullRequestProvider.make, }), }); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 0c18ff63331ab11cd50d9d676018bb11eb75723c..ca66433613524c0745e55dce5e9976128c5c1e7c 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -3,7 +3,16 @@ * * @module source-control-github/client/definition */ -import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts"; +import { + GitHubHost, + makeProviderSettingsSchema, + pullRequestHostOf, + SourceControlProviderKind, + TrimmedNonEmptyString, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { defineSourceControlClient, isChangeRequestInProjectRepository, @@ -17,6 +26,30 @@ const CHANGE_REQUEST_REFERENCE = const KIND = SourceControlProviderKind.make("github"); +export const GitHubHostChoice = Schema.Struct({ + account: Schema.optionalKey(TrimmedNonEmptyString), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type GitHubHostChoice = typeof GitHubHostChoice.Type; + +/** + * Per-host choices, keyed by lowercased host such as `github.com`. `hosts` pins one of the logins + * `gh` holds for a host instead of its active one, or turns the host off; `tokens` holds a token + * per host, used before `GH_TOKEN` and friends, which win over `gh`. Source Control settings draws + * both with GitHub's own panels, since the logins come from `gh` at runtime. + */ +export const settings = makeProviderSettingsSchema({ + hosts: Schema.Record(GitHubHost, GitHubHostChoice).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + tokens: Schema.Record(GitHubHost, TrimmedString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + Schema.annotateKey({ providerSettingsForm: { hidden: true, secret: true } }), + ), +}); +export type GitHubSettings = typeof settings.Type; + export const definition = defineSourceControlClient({ kind: KIND, label: "GitHub", @@ -60,4 +93,5 @@ export const definition = defineSourceControlClient({ canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), + settings, }); diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts index 15ac6bf648a7b481adede99eb90ce6b770902897..76cced6bda83f10378d0e3aa8a5978b84fbfd64e 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.test.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -57,7 +57,7 @@ function harness( Layer.provideMerge( TestSourceControlHost.layer({ settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - github: { hosts: hostSettings(hosts), tokens }, + sourceControlHosts: { github: { hosts: hostSettings(hosts), tokens } }, }), process: { run }, }), @@ -128,16 +128,20 @@ describe("GitHubCredentials", () => { expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); yield* settings.update({ - github: { hosts: { "github.com": { account: "work", enabled: true } } }, + sourceControlHosts: { + github: { hosts: { "github.com": { account: "work", enabled: true } } }, + }, }); expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); - yield* settings.update({ github: { hosts: { "github.com": { enabled: false } } } }); + yield* settings.update({ + sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } }, + }); expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( "GitHubHostDisabledError", ); - yield* settings.update({ github: { hosts: {} } }); + yield* settings.update({ sourceControlHosts: { github: { hosts: {} } } }); expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); // The unpinned token stayed cached; only the newly pinned account cost a gh call. expect(calls).toHaveLength(2); diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index 9eaa52301c04fe7605e5c41c12d93fa4bc97a0d9..69594c9455a741d17e6d2140e2d4c390f26866b4 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -14,6 +14,10 @@ import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; +import type { ServerSettings } from "@t3tools/contracts"; + +import * as GitHubClient from "../client/definition.ts"; /** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ const TOKEN_TTL = Duration.minutes(5); @@ -176,17 +180,23 @@ export const make = Effect.gen(function* () { ), ); + const readSettings = (settings: ServerSettings) => + readSourceControlHostSettings( + GitHubClient.settings, + settings.sourceControlHosts[GitHubClient.definition.kind], + ); + /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ const hostChoice = (host: string) => sourceControlHost.settings.get.pipe( - Effect.map((settings) => settings.github.hosts[host]), + Effect.map((settings) => readSettings(settings).hosts[host]), Effect.orElseSucceed(() => undefined), ); /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ const savedToken = (host: string) => sourceControlHost.settings.get.pipe( - Effect.map((settings) => settings.github.tokens[host]?.trim() || null), + Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), Effect.orElseSucceed(() => null), ); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 66ac324aaed59fd47cf1d3533c80385e78710a22..c52b3c2dd2c85f5a1f2a5775a5fb6dee6926feda 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -10,11 +10,9 @@ import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { - DEFAULT_SERVER_SETTINGS, SourceControlProviderError, TrimmedNonEmptyString, type ChangeRequest, - type GitHubSettings, type SourceControlProviderDiscoveryItem, type SourceControlRepositoryCloneUrls, } from "@t3tools/contracts"; @@ -55,6 +53,9 @@ import { type SourceControlManagedCliDiscoverySpec, } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; + +import * as GitHubClient from "../client/definition.ts"; const decodeLinkSubject = Schema.decodeUnknownEffect( Schema.fromJsonString( @@ -85,7 +86,7 @@ function authAccounts(accounts: ReadonlyArray) { */ export function parseGitHubAuth( input: SourceControlAuthProbeInput, - settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, + settings: GitHubClient.GitHubSettings = readSourceControlHostSettings(GitHubClient.settings, {}), ) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -202,9 +203,12 @@ export const makeDiscovery = Effect.gen(function* () { label: discovery.label, installHint: discovery.installHint, probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { - const settings = yield* sourceControlHost.settings.get.pipe( - Effect.map((current) => current.github), - Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), + const settings = readSourceControlHostSettings( + GitHubClient.settings, + yield* sourceControlHost.settings.get.pipe( + Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]), + Effect.orElseSucceed(() => undefined), + ), ); const cli = yield* probeSourceControlProvider({ cwd, diff --git a/packages/source-control-github/src/server/gitHubAuthStatus.ts b/packages/source-control-github/src/server/gitHubAuthStatus.ts index 7321ead2d4ec1f83bba46fb207ad3d75f68cf4af..c894879a68d33c504d12496d0ac6d4860747bec0 100644 --- a/packages/source-control-github/src/server/gitHubAuthStatus.ts +++ b/packages/source-control-github/src/server/gitHubAuthStatus.ts @@ -1,4 +1,4 @@ -import type { GitHubSettings } from "@t3tools/contracts"; +import type { GitHubSettings } from "../client/definition.ts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema";