authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 13:23:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 13:23:20-07:00
log4f63275ac7885281d245022e65f1e9e8816b1382
tree807380f1b61906a4a3acc3a177837c397d3ba091
parent2a4ee339827e480da6ad0139585cf634a46ff73e
signature Signed by PGP key B5690EEEBB952194

feat(source-control): host settings live on each host's definition, with a GitCafe token (#17901)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

34 files changed, 979 insertions(+), 634 deletions(-)

apps/server/src/serverSettings.test.ts+96-106
......@@ -26,6 +26,7 @@ import * as Stream from "effect/Stream";
2626import * as TestClock from "effect/testing/TestClock";
2727import * as SqlClient from "effect/sql/SqlClient";
2828import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
29import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts";
2930import * as ServerConfig from "./config.ts";
3031import * as SqlitePersistence from "./persistence/Sqlite.ts";
3132import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
......@@ -1418,7 +1419,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
14181419 );
14191420
14201421 it.effect(
1421 "keeps Bitbucket tokens in the secret store and tells clients only that one is set",
1422 "keeps source control host secrets in the secret store and tells clients only that one is set",
14221423 () =>
14231424 Effect.gen(function* () {
14241425 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
......@@ -1427,139 +1428,134 @@ it.layer(NodeServices.layer)("server settings", (it) => {
14271428 const fileSystem = yield* FileSystem.FileSystem;
14281429
14291430 const saved = yield* serverSettings.updateSettings({
1430 bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" },
1431 sourceControlHosts: {
1432 bitbucket: { email: "me@example.com", accessToken: "bb-access" },
1433 github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } },
1434 },
14311435 });
1432 assert.deepEqual(saved.bitbucket, {
1436 assert.deepEqual(saved.sourceControlHosts.bitbucket, {
14331437 email: "me@example.com",
14341438 accessToken: "bb-access",
1435 apiToken: "bb-api",
14361439 });
1437
14381440 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1439 assert.notInclude(raw, "bb-access");
1440 assert.notInclude(raw, "bb-api");
1441 for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret);
14411442 assert.include(raw, "me@example.com");
14421443
1443 const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket;
1444 assert.equal(forClient.email, "me@example.com");
1445 assert.notInclude(forClient.accessToken, "bb-access");
1446 assert.notInclude(forClient.apiToken, "bb-api");
1447 assert.isAbove(forClient.accessToken.length, 0);
1448 assert.isAbove(forClient.apiToken.length, 0);
1449
1450 // A client echoing the redacted values back, or omitting them, keeps the saved tokens.
1451 yield* serverSettings.updateSettings({ bitbucket: forClient });
1452 yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } });
1453 assert.deepEqual((yield* serverSettings.getSettings).bitbucket, {
1454 email: "other@example.com",
1455 accessToken: "bb-access",
1456 apiToken: "bb-api",
1457 });
1458
1459 const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } });
1460 assert.equal(cleared.bitbucket.accessToken, "");
1461 assert.equal(cleared.bitbucket.apiToken, "bb-api");
1462 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1463 assert.equal(
1464 ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken,
1465 "",
1466 );
1467 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1468 );
1469
1470 it.effect(
1471 "keeps GitHub tokens per host in the secret store and tells clients only that one is set",
1472 () =>
1473 Effect.gen(function* () {
1474 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1475 const secrets = yield* ServerSecretStore.ServerSecretStore;
1476 const serverConfig = yield* ServerConfig.ServerConfig;
1477 const fileSystem = yield* FileSystem.FileSystem;
1444 const forClient =
1445 ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts;
1446 assert.equal(forClient.bitbucket?.email, "me@example.com");
1447 assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access");
1448 assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0);
14781449
1479 const saved = yield* serverSettings.updateSettings({
1480 github: { tokens: { "GitHub.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } },
1481 });
1482 assert.deepEqual(saved.github.tokens, {
1483 "github.com": "ghp_dotcom",
1484 "ghe.acme.test": "ghp_ghe",
1485 });
1486 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1487 assert.notInclude(raw, "ghp_dotcom");
1488 assert.notInclude(raw, "ghp_ghe");
1489
1490 const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).github;
1491 assert.notInclude(forClient.tokens["github.com"]!, "ghp_dotcom");
1492 assert.isAbove(forClient.tokens["github.com"]!.length, 0);
1493
1494 // Echoing the redacted values back keeps them; host and account changes leave tokens alone.
1495 yield* serverSettings.updateSettings({ github: { tokens: forClient.tokens } });
1450 // Echoing the redacted values back keeps them; other fields change on their own.
1451 yield* serverSettings.updateSettings({ sourceControlHosts: forClient });
14961452 yield* serverSettings.updateSettings({
1497 github: { hosts: { "github.com": { enabled: true, account: "work" } } },
1453 sourceControlHosts: { bitbucket: { email: "other@example.com" } },
14981454 });
1499 assert.deepEqual((yield* serverSettings.getSettings).github.tokens, {
1455 const kept = (yield* serverSettings.getSettings).sourceControlHosts;
1456 assert.deepEqual(kept.bitbucket, { email: "other@example.com", accessToken: "bb-access" });
1457 assert.deepEqual(kept.github?.tokens, {
15001458 "github.com": "ghp_dotcom",
15011459 "ghe.acme.test": "ghp_ghe",
15021460 });
15031461
1504 // An empty token removes that host's token and nothing else.
1462 // An empty value removes that secret, and one per server host leaves the others.
15051463 const cleared = yield* serverSettings.updateSettings({
1506 github: { tokens: { "github.com": "" } },
1464 sourceControlHosts: {
1465 bitbucket: { accessToken: "" },
1466 github: {
1467 tokens: {
1468 "github.com": "",
1469 "ghe.acme.test": (forClient.github!.tokens as Record<string, string>)[
1470 "ghe.acme.test"
1471 ],
1472 },
1473 },
1474 },
15071475 });
1508 assert.equal(cleared.github.tokens["github.com"] ?? "", "");
1509 assert.equal(cleared.github.tokens["ghe.acme.test"], "ghp_ghe");
1510 const remaining = yield* Effect.forEach(["github.com", "ghe.acme.test"], (host) =>
1511 secrets.get(`github-token-${Buffer.from(host, "utf8").toString("base64url")}`),
1512 );
1513 assert.isTrue(Option.isNone(remaining[0]!));
1514 assert.isTrue(Option.isSome(remaining[1]!));
1476 assert.equal(cleared.sourceControlHosts.bitbucket?.accessToken, "");
1477 const stored = (kind: string, field: string, serverHost: string | null) =>
1478 secrets.get(sourceControlHostSecretName({ kind, field, serverHost }));
1479 assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null)));
1480 assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com")));
1481 assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test")));
15151482 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
15161483 );
15171484
1518 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>
1519 Effect.gen(function* () {
1485 it.effect("keeps the retired keys for another load when a token cannot be copied", () => {
1486 const cause = new ServerSecretStore.SecretStoreReadError({
1487 resource: "bitbucket-access-token",
1488 cause: PlatformError.systemError({
1489 _tag: "PermissionDenied",
1490 module: "FileSystem",
1491 method: "readFile",
1492 pathOrDescriptor: "bitbucket-access-token",
1493 description: "Secret backend unavailable.",
1494 }),
1495 });
1496 const layerSettings = ServerSettingsModule.layer.pipe(
1497 Layer.provide(layerFailingSecretStore(cause)),
1498 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
1499 Layer.provideMerge(
1500 Layer.fresh(
1501 ServerConfig.layerTest(process.cwd(), {
1502 prefix: "t3code-server-settings-legacy-failure-test-",
1503 }),
1504 ),
1505 ),
1506 );
1507 return Effect.gen(function* () {
15201508 const serverConfig = yield* ServerConfig.ServerConfig;
15211509 const fileSystem = yield* FileSystem.FileSystem;
1522 const secrets = yield* ServerSecretStore.ServerSecretStore;
15231510 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1524 // A token was saved, then the user deleted it from settings.json directly.
1525 yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token"));
1526 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
1511 const legacy = JSON.stringify({
1512 bitbucket: { email: "me@example.com", accessToken: "\u2022\u2022\u2022\u2022\u2022\u2022" },
1513 });
1514 yield* fileSystem.writeFileString(serverConfig.settingsPath, legacy);
15271515
1528 yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true });
1516 yield* Effect.exit(serverSettings.getSettings);
15291517
1530 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1531 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1532 );
1518 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy);
1519 }).pipe(Effect.provide(layerSettings));
1520 });
15331521
1534 it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () =>
1522 it.effect("moves the retired bitbucket and github keys and their secrets on load", () =>
15351523 Effect.gen(function* () {
15361524 const serverConfig = yield* ServerConfig.ServerConfig;
15371525 const fileSystem = yield* FileSystem.FileSystem;
15381526 const secrets = yield* ServerSecretStore.ServerSecretStore;
15391527 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1528 const marker = "\u2022\u2022\u2022\u2022\u2022\u2022";
1529 yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("bb-saved"));
1530 yield* secrets.set(
1531 `github-token-${Buffer.from("github.com", "utf8").toString("base64url")}`,
1532 new TextEncoder().encode("ghp_saved"),
1533 );
15401534 yield* fileSystem.writeFileString(
15411535 serverConfig.settingsPath,
1542 '{"bitbucket":{"accessToken":"hand-edited-token"}}',
1536 JSON.stringify({
1537 bitbucket: { email: "me@example.com", accessToken: marker },
1538 github: {
1539 hosts: { "github.com": { account: "work" } },
1540 tokens: { "github.com": marker },
1541 },
1542 }),
15431543 );
15441544
1545 // Loading alone moves it: no settings update is needed.
1546 const loaded = yield* serverSettings.getSettings;
1545 const loaded = (yield* serverSettings.getSettings).sourceControlHosts;
15471546
1548 assert.equal(loaded.bitbucket.accessToken, "hand-edited-token");
1549 assert.notInclude(
1550 yield* fileSystem.readFileString(serverConfig.settingsPath),
1551 "hand-edited-token",
1552 );
1553 const stored = yield* secrets.get("bitbucket-access-token");
1554 assert.equal(
1555 Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null,
1556 "hand-edited-token",
1557 );
1547 assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" });
1548 assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" });
1549 assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } });
1550 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1551 assert.notInclude(raw, '"bitbucket":{"email"');
1552 assert.include(raw, "sourceControlHosts");
1553 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
15581554 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
15591555 );
15601556
15611557 it.effect(
1562 "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker",
1558 "moves a hand-edited source control token into the secret store when settings load",
15631559 () =>
15641560 Effect.gen(function* () {
15651561 const serverConfig = yield* ServerConfig.ServerConfig;
......@@ -1567,24 +1563,18 @@ it.layer(NodeServices.layer)("server settings", (it) => {
15671563 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
15681564 yield* fileSystem.writeFileString(
15691565 serverConfig.settingsPath,
1570 '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}',
1566 '{"sourceControlHosts":{"bitbucket":{"accessToken":"hand-edited-token"}}}',
15711567 );
15721568
1573 // The form resends the redacted token when only the email changes.
1574 const forClient = ServerSettingsModule.redactServerSettingsForClient(
1575 yield* serverSettings.getSettings,
1576 ).bitbucket;
1577 const updated = yield* serverSettings.updateSettings({
1578 bitbucket: { email: "new@example.com", apiToken: forClient.apiToken },
1579 });
1569 // Loading alone moves it: no settings update is needed.
1570 const loaded = yield* serverSettings.getSettings;
15801571
1581 assert.equal(updated.bitbucket.apiToken, "hand-edited-token");
1582 assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token");
1572 assert.equal(loaded.sourceControlHosts.bitbucket?.accessToken, "hand-edited-token");
15831573 assert.notInclude(
15841574 yield* fileSystem.readFileString(serverConfig.settingsPath),
15851575 "hand-edited-token",
15861576 );
1587 }).pipe(Effect.provide(layerServerSettings())),
1577 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
15881578 );
15891579
15901580 it.effect("materializes provider secrets for terminal environment resolution", () =>
apps/server/src/serverSettings.ts+202-127
......@@ -64,6 +64,13 @@ import {
6464 isModelSelectionProviderEnabled,
6565} from "@t3tools/shared/serverSettings";
6666import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
67import {
68 readSecretFieldValues,
69 SOURCE_CONTROL_HOST_SECRET_FIELDS,
70 type SourceControlHostSecretSlot,
71 sourceControlHostSecretName,
72 writeSecretFieldValues,
73} from "./sourceControl/sourceControlHostSecrets.ts";
6774
6875export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
6976
......@@ -157,18 +164,47 @@ function usageLimitSourceSecretName(sourceId: string): string {
157164 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
158165}
159166
160const BITBUCKET_SECRET_NAMES = {
161 accessToken: "bitbucket-access-token",
162 apiToken: "bitbucket-api-token",
163} as const;
164const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;
167const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
165168
166/** Hosts are case-insensitive; a patch can arrive before decoding lowercased its keys. */
167function gitHubTokenSecretName(host: string): string {
168 return `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`;
169/** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */
170function mapSlotValues(
171 hosts: ServerSettings["sourceControlHosts"],
172 map: (slot: SourceControlHostSecretSlot, value: string) => string,
173): ServerSettings["sourceControlHosts"] {
174 return Object.fromEntries(
175 Object.entries(hosts).map(([kind, fields]) => {
176 const secretFields = SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? [];
177 if (secretFields.length === 0) return [kind, fields];
178 const next = { ...fields };
179 for (const field of secretFields) {
180 if (!(field in next)) continue;
181 next[field] = writeSecretFieldValues(
182 next[field],
183 readSecretFieldValues(next[field]).map(
184 ([serverHost, value]) => [serverHost, map({ kind, field, serverHost }, value)] as const,
185 ),
186 );
187 }
188 return [kind, next];
189 }),
190 );
169191}
170192
171const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
193/** Every saved secret slot in `hosts`, with its current value. */
194function sourceControlHostSecretSlots(
195 hosts: ServerSettings["sourceControlHosts"],
196): ReadonlyArray<SourceControlHostSecretSlot & { readonly value: string }> {
197 return Object.entries(hosts).flatMap(([kind, fields]) =>
198 (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) =>
199 readSecretFieldValues(fields[field]).map(([serverHost, value]) => ({
200 kind,
201 field,
202 serverHost,
203 value,
204 })),
205 ),
206 );
207}
172208
173209function redactProviderEnvironmentVariable(
174210 variable: ProviderInstanceEnvironmentVariable,
......@@ -206,18 +242,10 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
206242 },
207243 ]),
208244 );
209 const bitbucket = {
210 ...settings.bitbucket,
211 accessToken: redactSecret(settings.bitbucket.accessToken),
212 apiToken: redactSecret(settings.bitbucket.apiToken),
213 };
214 const github = {
215 ...settings.github,
216 tokens: Object.fromEntries(
217 Object.entries(settings.github.tokens).map(([host, token]) => [host, redactSecret(token)]),
218 ),
219 };
220 return { ...settings, providerInstances, usageLimitSources, bitbucket, github };
245 const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) =>
246 redactSecret(value),
247 );
248 return { ...settings, providerInstances, usageLimitSources, sourceControlHosts };
221249}
222250
223251export function applyProviderInstanceMutation(
......@@ -394,10 +422,60 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson
394422const LegacyProviderSettingsJson = fromLenientJson(
395423 Schema.Struct({
396424 providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
425 // Retired for `sourceControlHosts`; read only by `migrateLegacySourceControlSettings`.
426 bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
427 github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
397428 }),
398429);
399430const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson);
400431
432/** The secret store names the retired `bitbucket` and `github` keys kept their tokens under. */
433const LEGACY_BITBUCKET_SECRET_NAMES: Readonly<Record<string, string>> = {
434 accessToken: "bitbucket-access-token",
435 apiToken: "bitbucket-api-token",
436};
437const legacyGitHubTokenSecretName = (host: string) =>
438 `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`;
439
440/**
441 * Folds the retired `bitbucket` and `github` keys into `sourceControlHosts`, unless the new key
442 * already holds that host. Returns the secret store entries to copy to their new names.
443 */
444function migrateLegacySourceControlSettings(
445 settings: ServerSettings,
446 legacy: {
447 readonly bitbucket?: Readonly<Record<string, unknown>> | undefined;
448 readonly github?: Readonly<Record<string, unknown>> | undefined;
449 },
450): {
451 readonly settings: ServerSettings;
452 readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>;
453} {
454 const sourceControlHosts = { ...settings.sourceControlHosts };
455 const secretMoves: Array<{ readonly from: string; readonly to: string }> = [];
456 if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) {
457 sourceControlHosts.bitbucket = legacy.bitbucket;
458 for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) {
459 if (legacy.bitbucket[field] !== SECRET_REDACTED) continue;
460 secretMoves.push({
461 from,
462 to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }),
463 });
464 }
465 }
466 if (legacy.github !== undefined && sourceControlHosts.github === undefined) {
467 sourceControlHosts.github = legacy.github;
468 for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) {
469 if (serverHost === null || value !== SECRET_REDACTED) continue;
470 secretMoves.push({
471 from: legacyGitHubTokenSecretName(serverHost),
472 to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }),
473 });
474 }
475 }
476 return { settings: { ...settings, sourceControlHosts }, secretMoves };
477}
478
401479// Drivers that start disabled, so a session in the history means the user
402480// turned them on before the instance kept an explicit flag.
403481const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(
......@@ -724,54 +802,41 @@ const make = Effect.gen(function* () {
724802 );
725803
726804 /**
727 * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load,
728 * so plaintext does not stay on disk. If the store is unavailable, the token keeps working
729 * from the file and the move is retried on the next load.
805 * Moves source control host secrets hand-edited into settings.json into the secret store as
806 * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps
807 * working from the file and the move is retried on the next load.
730808 */
731 const moveInlineBitbucketTokens = (settings: ServerSettings) =>
809 const moveInlineSourceControlHostSecrets = (settings: ServerSettings) =>
732810 Effect.gen(function* () {
733 const bitbucket = { ...settings.bitbucket };
734 let moved = false;
735 for (const field of BITBUCKET_SECRET_FIELDS) {
736 const value = bitbucket[field];
737 if (value.length === 0 || value === SECRET_REDACTED) continue;
811 const moved = new Set<string>();
812 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {
813 if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue;
738814 const stored = yield* secretStore
739 .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value))
815 .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value))
740816 .pipe(
741817 Effect.as(true),
742818 Effect.catch(() =>
743 Effect.logWarning("failed to move a Bitbucket token into the secret store", {
744 field,
819 Effect.logWarning("failed to move a source control secret into the secret store", {
820 kind: slot.kind,
821 field: slot.field,
745822 }).pipe(Effect.as(false)),
746823 ),
747824 );
748 if (!stored) continue;
749 bitbucket[field] = SECRET_REDACTED;
750 moved = true;
825 if (stored) moved.add(sourceControlHostSecretName(slot));
751826 }
752 const tokens = { ...settings.github.tokens };
753 for (const [host, value] of Object.entries(tokens)) {
754 if (value.length === 0 || value === SECRET_REDACTED) continue;
755 const stored = yield* secretStore
756 .set(gitHubTokenSecretName(host), textEncoder.encode(value))
757 .pipe(
758 Effect.as(true),
759 Effect.catch(() =>
760 Effect.logWarning("failed to move a GitHub token into the secret store", {
761 host,
762 }).pipe(Effect.as(false)),
763 ),
764 );
765 if (!stored) continue;
766 tokens[host] = SECRET_REDACTED;
767 moved = true;
768 }
769 return moved ? { ...settings, bitbucket, github: { ...settings.github, tokens } } : settings;
827 if (moved.size === 0) return settings;
828 return {
829 ...settings,
830 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>
831 moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value,
832 ),
833 };
770834 });
771835
772836 const loadSettingsFromDisk = Effect.gen(function* () {
773837 let settings = DEFAULT_SERVER_SETTINGS;
774838 let legacyProviders: Readonly<Record<string, unknown>> | undefined;
839 let legacySourceControl: Parameters<typeof migrateLegacySourceControlSettings>[1] = {};
775840 // A file that failed to decode must stay on disk for the user to repair;
776841 // the fold below only writes when it started from the file's real contents.
777842 let settingsFileTrusted = true;
......@@ -782,6 +847,10 @@ const make = Effect.gen(function* () {
782847 const legacySettings = decodeLegacyProviderSettingsJsonExit(raw);
783848 if (legacySettings._tag === "Success") {
784849 legacyProviders = legacySettings.value.providers;
850 legacySourceControl = {
851 bitbucket: legacySettings.value.bitbucket,
852 github: legacySettings.value.github,
853 };
785854 }
786855 if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
787856 const failure = decoded._tag === "Failure" ? decoded : legacySettings;
......@@ -855,12 +924,48 @@ const make = Effect.gen(function* () {
855924 const folded = settingsFileTrusted
856925 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
857926 : loaded;
927 // Copied, not moved: the old entry is removed only after the new file is written. A token
928 // that cannot be read or copied leaves the retired keys in place, so the migration runs again
929 // on the next load instead of dropping it.
930 const legacySecretsCopied = yield* Effect.forEach(
931 settingsFileTrusted
932 ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves
933 : [],
934 (move) =>
935 secretStore.get(move.from).pipe(
936 Effect.flatMap((secret) =>
937 Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value),
938 ),
939 Effect.as(true),
940 Effect.catch(() =>
941 Effect.logWarning("failed to copy a source control secret to its new name", {
942 secretName: move.to,
943 }).pipe(Effect.as(false)),
944 ),
945 ),
946 ).pipe(Effect.map((copied) => copied.every(Boolean)));
947 const hasLegacySourceControl =
948 settingsFileTrusted &&
949 legacySecretsCopied &&
950 (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined);
951 const sourceControlMigration = hasLegacySourceControl
952 ? migrateLegacySourceControlSettings(folded, legacySourceControl)
953 : { settings: folded, secretMoves: [] };
858954 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
859 const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;
860 // A file still carrying the retired `providers` map is rewritten once so
861 // the migrated instances persist and the old key disappears.
862 if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) {
955 const migrated = settingsFileTrusted
956 ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings)
957 : folded;
958 // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten
959 // once so the migrated values persist and the old keys disappear.
960 if (
961 migrated !== loaded ||
962 (settingsFileTrusted && legacyProviders !== undefined) ||
963 hasLegacySourceControl
964 ) {
863965 yield* writeSettingsAtomically(migrated);
966 for (const move of sourceControlMigration.secretMoves) {
967 yield* secretStore.remove(move.from).pipe(Effect.ignore);
968 }
864969 }
865970 return migrated;
866971 });
......@@ -933,39 +1038,30 @@ const make = Effect.gen(function* () {
9331038 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
9341039 };
9351040 }
936 const bitbucket = { ...settings.bitbucket };
937 for (const field of BITBUCKET_SECRET_FIELDS) {
938 if (bitbucket[field] !== SECRET_REDACTED) continue;
939 const secret = yield* secretStore
940 .get(BITBUCKET_SECRET_NAMES[field])
941 .pipe(
942 Effect.mapError(
943 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
944 ),
945 );
946 bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
947 }
948 const tokens: Record<string, string> = {};
949 for (const [host, value] of Object.entries(settings.github.tokens)) {
950 if (value !== SECRET_REDACTED) {
951 tokens[host] = value;
952 continue;
953 }
1041 const storedSecrets = new Map<string, string>();
1042 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {
1043 if (slot.value !== SECRET_REDACTED) continue;
9541044 const secret = yield* secretStore
955 .get(gitHubTokenSecretName(host))
1045 .get(sourceControlHostSecretName(slot))
9561046 .pipe(
9571047 Effect.mapError(
9581048 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
9591049 ),
9601050 );
961 tokens[host] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
1051 storedSecrets.set(
1052 sourceControlHostSecretName(slot),
1053 Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
1054 );
9621055 }
9631056 return {
9641057 ...settings,
9651058 providerInstances: providerInstances as ServerSettings["providerInstances"],
9661059 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
967 bitbucket,
968 github: { ...settings.github, tokens },
1060 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>
1061 value === SECRET_REDACTED
1062 ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "")
1063 : value,
1064 ),
9691065 };
9701066 });
9711067
......@@ -1107,56 +1203,36 @@ const make = Effect.gen(function* () {
11071203 });
11081204 }
11091205
1110 const bitbucket = { ...next.bitbucket };
1111 for (const field of BITBUCKET_SECRET_FIELDS) {
1112 let value = bitbucket[field];
1113 if (value === SECRET_REDACTED) {
1114 // The marker keeps what is saved. A plaintext value hand-edited into settings.json
1115 // is not in the secret store yet, so move it there instead of dropping it.
1116 const inline = current.bitbucket[field];
1117 if (inline === SECRET_REDACTED || inline.length === 0) continue;
1118 value = inline;
1119 }
1120 const secretName = BITBUCKET_SECRET_NAMES[field];
1121 if (value.length === 0) {
1122 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
1123 continue;
1124 }
1125 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
1126 bitbucket[field] = SECRET_REDACTED;
1127 }
1128
1129 const tokens: Record<string, string> = {};
1130 for (const [rawHost, raw] of Object.entries(next.github.tokens)) {
1131 const host = rawHost.trim().toLowerCase();
1132 let value = raw;
1133 if (value === SECRET_REDACTED) {
1134 // The marker keeps what is saved; a hand-edited plaintext token moves into the store.
1135 const inline = current.github.tokens[host];
1136 if (inline === undefined || inline === SECRET_REDACTED || inline.length === 0) {
1137 tokens[host] = SECRET_REDACTED;
1138 continue;
1139 }
1140 value = inline;
1206 // Hand-edited plaintext not yet in the store, so the redaction marker can move it there.
1207 const inlineSecrets = new Map(
1208 sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) =>
1209 slot.value.length > 0 && slot.value !== SECRET_REDACTED
1210 ? [[sourceControlHostSecretName(slot), slot.value] as const]
1211 : [],
1212 ),
1213 );
1214 const nextSecretNames = new Set<string>();
1215 const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => {
1216 const secretName = sourceControlHostSecretName(slot);
1217 // The marker keeps what is saved; a hand-edited plaintext value moves into the store.
1218 const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw;
1219 if (value === undefined) {
1220 nextSecretNames.add(secretName);
1221 return SECRET_REDACTED;
11411222 }
1142 const secretName = gitHubTokenSecretName(host);
11431223 if (value.length === 0) {
11441224 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
1145 continue;
1225 return "";
11461226 }
1227 nextSecretNames.add(secretName);
11471228 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
1148 tokens[host] = SECRET_REDACTED;
1149 }
1150 const nextHosts = new Set(
1151 Object.keys(next.github.tokens).map((host) => host.trim().toLowerCase()),
1152 );
1153 for (const host of Object.keys(current.github.tokens)) {
1154 if (nextHosts.has(host.trim().toLowerCase())) continue;
1155 changes.push({
1156 kind: "remove",
1157 secretName: gitHubTokenSecretName(host),
1158 operation: "remove-stale-secret",
1159 });
1229 return SECRET_REDACTED;
1230 });
1231 for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) {
1232 const secretName = sourceControlHostSecretName(slot);
1233 if (nextSecretNames.has(secretName)) continue;
1234 if (changes.some((change) => change.secretName === secretName)) continue;
1235 changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" });
11601236 }
11611237
11621238 return {
......@@ -1164,8 +1240,7 @@ const make = Effect.gen(function* () {
11641240 ...next,
11651241 providerInstances: providerInstances as ServerSettings["providerInstances"],
11661242 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
1167 bitbucket,
1168 github: { ...next.github, tokens },
1243 sourceControlHosts,
11691244 },
11701245 changes,
11711246 };
apps/server/src/sourceControl/BitbucketApi.test.ts+15-5
......@@ -632,16 +632,22 @@ it.effect("prefers credentials saved in settings over the environment, without a
632632 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
633633
634634 yield* settings.updateSettings({
635 bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" },
635 sourceControlHosts: {
636 bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" },
637 },
636638 });
637639 yield* bitbucket.probeAuth;
638640 assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token"));
639641
640 yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } });
642 yield* settings.updateSettings({
643 sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } },
644 });
641645 yield* bitbucket.probeAuth;
642646 assert.strictEqual(lastAuthorization(), "Bearer saved-access-token");
643647
644 yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } });
648 yield* settings.updateSettings({
649 sourceControlHosts: { bitbucket: { accessToken: "", apiToken: "" } },
650 });
645651 yield* bitbucket.probeAuth;
646652 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
647653 }).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
658664
659665 // Fetch would reject this header with an error quoting the token, and that error reaches
660666 // clients. The unusable token is ignored, so the environment credential is used instead.
661 yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } });
667 yield* settings.updateSettings({
668 sourceControlHosts: { bitbucket: { accessToken: "saved\ntoken" } },
669 });
662670 yield* bitbucket.probeAuth;
663671 assert.strictEqual(
664672 execute.mock.calls.at(-1)?.[0].headers.authorization,
......@@ -679,7 +687,9 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm
679687
680688 assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated");
681689
682 yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } });
690 yield* settings.updateSettings({
691 sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } },
692 });
683693 assert.deepStrictEqual(yield* bitbucket.probeAuth, {
684694 status: "unknown",
685695 account: Option.none(),
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+4
......@@ -27,6 +27,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
2727import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2828import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
2929import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
30import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
3031import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
3132import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
3233import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
......@@ -56,6 +57,9 @@ const layerSourceControlProviderRegistryTest = (input: {
5657 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
5758 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
5859 Layer.mock(GitCafeApi.GitCafeApi)({}),
60 Layer.mock(GitCafeCredentials.GitCafeCredentials)({
61 cliEnv: () => Effect.succeed({}),
62 }),
5963 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
6064 Layer.mock(VcsProcess.VcsProcess)(input.process),
6165 ServerSourceControlHost.layer.pipe(
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+4
......@@ -26,6 +26,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
2626import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
2727import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
2828import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
29import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
2930import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
3031import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
3132
......@@ -129,6 +130,9 @@ function makeRegistry(input: {
129130 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
130131 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
131132 Layer.mock(GitCafeApi.GitCafeApi)({}),
133 Layer.mock(GitCafeCredentials.GitCafeCredentials)({
134 cliEnv: () => Effect.succeed({}),
135 }),
132136 ServerConfig.layerTest(process.cwd(), {
133137 prefix: "t3-source-control-registry-test-",
134138 }).pipe(Layer.provide(NodeServices.layer)),
apps/server/src/sourceControl/sourceControlHostSecrets.ts created+76
......@@ -0,0 +1,76 @@
1/**
2 * Which source control host settings fields hold secrets, read from each built-in host's client
3 * definition. `serverSettings` keeps those values in the secret store and redacts them for
4 * clients, the same way for every host.
5 *
6 * A secret field holds a string, or a string per server host such as GitHub's `tokens`.
7 *
8 * @module sourceControl/sourceControlHostSecrets
9 */
10import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";
11import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition";
12import {
13 secretSourceControlHostSettingsFields,
14 type SourceControlClientDefinition,
15} from "@t3tools/source-control-core/client/definition";
16import * as Forgejo from "@t3tools/source-control-forgejo/client/definition";
17import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition";
18import * as GitHub from "@t3tools/source-control-github/client/definition";
19import * as GitLab from "@t3tools/source-control-gitlab/client/definition";
20
21const definitions: ReadonlyArray<SourceControlClientDefinition> = [
22 GitHub.definition,
23 GitLab.definition,
24 AzureDevOps.definition,
25 Bitbucket.definition,
26 Forgejo.definition,
27 GitCafe.definition,
28];
29
30/** Secret field names per host kind; a host with none is absent. */
31export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap<
32 string,
33 ReadonlyArray<string>
34> = new Map(
35 definitions.flatMap((definition) => {
36 const fields = definition.settings
37 ? secretSourceControlHostSettingsFields(definition.settings)
38 : [];
39 return fields.length > 0 ? [[definition.kind, fields] as const] : [];
40 }),
41);
42
43/** One stored secret: a host's field, and the server host it is for when the field is per host. */
44export interface SourceControlHostSecretSlot {
45 readonly kind: string;
46 readonly field: string;
47 readonly serverHost: string | null;
48}
49
50/** The secret store entry for one slot; names stay stable across releases. */
51export function sourceControlHostSecretName(slot: SourceControlHostSecretSlot): string {
52 const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url");
53 return slot.serverHost === null
54 ? `source-control-${encode(slot.kind)}-${encode(slot.field)}`
55 : `source-control-${encode(slot.kind)}-${encode(slot.field)}-${encode(slot.serverHost.trim().toLowerCase())}`;
56}
57
58/** The string values a secret field holds, keyed by server host, or `null` for a plain string. */
59export function readSecretFieldValues(
60 value: unknown,
61): ReadonlyArray<readonly [serverHost: string | null, value: string]> {
62 if (typeof value === "string") return [[null, value]];
63 if (value === null || typeof value !== "object" || Array.isArray(value)) return [];
64 return Object.entries(value).flatMap(([serverHost, entry]) =>
65 typeof entry === "string" ? [[serverHost.trim().toLowerCase(), entry] as const] : [],
66 );
67}
68
69/** A secret field's value rebuilt from per-slot values, in the shape it was read from. */
70export function writeSecretFieldValues(
71 original: unknown,
72 values: ReadonlyArray<readonly [serverHost: string | null, value: string]>,
73): unknown {
74 if (typeof original === "string") return values[0]?.[1] ?? "";
75 return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value]));
76}
apps/web/src/components/settings/BitbucketCredentialsSettings.tsx deleted-223
......@@ -1,223 +0,0 @@
1import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button, InlineButton } from "../ui/button";
9import { Input } from "../ui/input";
10import { Label } from "../ui/label";
11import { Toggle, ToggleGroup } from "../ui/toggle-group";
12
13type CredentialMethod = "access-token" | "api-token";
14
15const METHODS: Record<
16 CredentialMethod,
17 {
18 readonly label: string;
19 readonly description: string;
20 readonly link: string;
21 readonly linkLabel: string;
22 }
23> = {
24 "access-token": {
25 label: "Access token",
26 description:
27 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
28 link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/",
29 linkLabel: "Learn more",
30 },
31 "api-token": {
32 label: "API token",
33 description:
34 "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.",
35 link: "https://id.atlassian.com/manage-profile/security/api-tokens",
36 linkLabel: "Create an API token",
37 },
38};
39
40function savedMethod(saved: BitbucketSettings): CredentialMethod | null {
41 if (saved.accessToken.length > 0) return "access-token";
42 if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token";
43 return null;
44}
45
46/** A write-only token field. It never shows the saved token; typing a new one replaces it. */
47function TokenInput({
48 id,
49 isSaved,
50 draft,
51 onDraftChange,
52}: {
53 readonly id: string;
54 readonly isSaved: boolean;
55 readonly draft: string;
56 readonly onDraftChange: (draft: string) => void;
57}) {
58 return (
59 <Input
60 id={id}
61 type="password"
62 autoComplete="off"
63 size="sm"
64 placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"}
65 value={draft}
66 onChange={(event) => onDraftChange(event.target.value)}
67 />
68 );
69}
70
71/**
72 * Bitbucket credentials for one environment: an access token or an Atlassian
73 * account email + API token, never both. Tokens are write-only: the server
74 * keeps them in its secret store and only reports whether each one is set.
75 */
76export function BitbucketCredentialsSettings({
77 environmentId,
78 onSaved,
79}: {
80 readonly environmentId: EnvironmentId;
81 readonly onSaved: () => void;
82}) {
83 const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket);
84 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
85 label: "save Bitbucket credentials",
86 });
87 const [methodChoice, setMethodChoice] = useState<CredentialMethod | null>(null);
88 const [accessToken, setAccessToken] = useState("");
89 const [emailDraft, setEmailDraft] = useState<string | null>(null);
90 const [apiToken, setApiToken] = useState("");
91 const [saving, setSaving] = useState(false);
92 const current = savedMethod(saved);
93 const method = methodChoice ?? current ?? "access-token";
94 const methodIsSaved = current === method;
95 const email = (emailDraft ?? saved.email).trim();
96 const newAccessToken = accessToken.trim();
97 const newApiToken = apiToken.trim();
98 const info = METHODS[method];
99
100 // Saving one method clears the other, so a hidden credential never wins over the visible one.
101 const patch: BitbucketSettings | null =
102 method === "access-token"
103 ? newAccessToken
104 ? { accessToken: newAccessToken, email: "", apiToken: "" }
105 : null
106 : email && (newApiToken || saved.apiToken)
107 ? // Resending the saved token's redacted value keeps it.
108 { accessToken: "", email, apiToken: newApiToken || saved.apiToken }
109 : null;
110 const canSave =
111 patch !== null &&
112 (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email);
113
114 const save = async (next: BitbucketSettings) => {
115 setSaving(true);
116 try {
117 const result = await updateSettings({
118 environmentId,
119 input: { patch: { bitbucket: next } },
120 });
121 if (result._tag === "Success") {
122 setAccessToken("");
123 setApiToken("");
124 setEmailDraft(null);
125 onSaved();
126 }
127 } finally {
128 setSaving(false);
129 }
130 };
131
132 return (
133 <form
134 className="grid gap-4"
135 onSubmit={(event) => {
136 event.preventDefault();
137 if (canSave && patch) void save(patch);
138 }}
139 >
140 {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */}
141 <fieldset disabled={saving} className="contents">
142 <ToggleGroup
143 aria-label="Bitbucket sign-in method"
144 variant="segmented"
145 value={[method]}
146 onValueChange={(next) => {
147 const value = next[0];
148 if (value === "access-token" || value === "api-token") setMethodChoice(value);
149 }}
150 >
151 <Toggle value="access-token">{METHODS["access-token"].label}</Toggle>
152 <Toggle value="api-token">{METHODS["api-token"].label}</Toggle>
153 </ToggleGroup>
154 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
155 {info.description}{" "}
156 <InlineButton render={<a href={info.link} target="_blank" rel="noreferrer noopener" />}>
157 {info.linkLabel}
158 <ExternalLinkIcon aria-hidden className="size-3" />
159 </InlineButton>
160 </p>
161 {method === "access-token" ? (
162 <div className="grid gap-1.5">
163 <Label htmlFor={`bitbucket-access-token-${environmentId}`}>Access token</Label>
164 <TokenInput
165 id={`bitbucket-access-token-${environmentId}`}
166 isSaved={methodIsSaved}
167 draft={accessToken}
168 onDraftChange={setAccessToken}
169 />
170 </div>
171 ) : (
172 <>
173 <div className="grid gap-1.5">
174 <Label htmlFor={`bitbucket-email-${environmentId}`}>Atlassian account email</Label>
175 <Input
176 id={`bitbucket-email-${environmentId}`}
177 type="email"
178 autoComplete="off"
179 size="sm"
180 placeholder="you@example.com"
181 value={emailDraft ?? saved.email}
182 onChange={(event) => setEmailDraft(event.target.value)}
183 />
184 </div>
185 <div className="grid gap-1.5">
186 <Label htmlFor={`bitbucket-api-token-${environmentId}`}>API token</Label>
187 <TokenInput
188 id={`bitbucket-api-token-${environmentId}`}
189 isSaved={methodIsSaved}
190 draft={apiToken}
191 onDraftChange={setApiToken}
192 />
193 </div>
194 </>
195 )}
196 <div className="flex items-center justify-between gap-3">
197 <p className="text-xs text-muted-foreground">
198 {current === null
199 ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables."
200 : methodIsSaved
201 ? null
202 : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`}
203 </p>
204 <div className="flex shrink-0 gap-2">
205 {current !== null ? (
206 <Button
207 size="xs"
208 variant="outline"
209 disabled={saving}
210 onClick={() => void save({ accessToken: "", email: "", apiToken: "" })}
211 >
212 Remove
213 </Button>
214 ) : null}
215 <Button type="submit" size="xs" disabled={!canSave || saving}>
216 Save
217 </Button>
218 </div>
219 </div>
220 </fieldset>
221 </form>
222 );
223}
apps/web/src/components/settings/GitHubAccountSettings.logic.ts+2-1
......@@ -1,4 +1,5 @@
1import type { GitHubSettings, SourceControlProviderAuth } from "@t3tools/contracts";
1import type { SourceControlProviderAuth } from "@t3tools/contracts";
2import type { GitHubSettings } from "@t3tools/client-runtime/source-control-clients";
23
34export type GitHubDiscoveredAccount = NonNullable<SourceControlProviderAuth["accounts"]>[number];
45
apps/web/src/components/settings/GitHubAccountSettings.tsx+8-2
......@@ -9,6 +9,7 @@ import { Button } from "../ui/button";
99import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
1010import { Switch } from "../ui/switch";
1111import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic";
12import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients";
1213import { redactedPlaceholder } from "./RedactedSensitiveText";
1314
1415/** Sentinel select value for "follow gh's active login"; logins never contain spaces. */
......@@ -49,7 +50,8 @@ export function GitHubAccountSettings({
4950 readonly auth: SourceControlProviderAuth;
5051 readonly onSaved: () => void;
5152}) {
52 const hosts = useEnvironmentSettings(environmentId, (settings) => settings.github.hosts);
53 const github = useEnvironmentSettings(environmentId, readGitHubSettings);
54 const hosts = github.hosts;
5355 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
5456 label: "save GitHub account settings",
5557 });
......@@ -65,7 +67,11 @@ export function GitHubAccountSettings({
6567 try {
6668 const result = await updateSettings({
6769 environmentId,
68 input: { patch: { github: { hosts: nextGitHubHosts(hosts, host, change) } } },
70 input: {
71 patch: {
72 sourceControlHosts: { github: { hosts: nextGitHubHosts(hosts, host, change) } },
73 },
74 },
6975 });
7076 if (result._tag === "Success") onSaved();
7177 } finally {
apps/web/src/components/settings/GitHubTokenSettings.tsx+6-2
......@@ -3,6 +3,7 @@ import { ExternalLinkIcon } from "lucide-react";
33import { useState } from "react";
44
55import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients";
67import { serverEnvironment } from "../../state/server";
78import { useAtomCommand } from "../../state/use-atom-command";
89import { Button, InlineButton } from "../ui/button";
......@@ -28,7 +29,7 @@ export function GitHubTokenSettings({
2829 readonly environmentId: EnvironmentId;
2930 readonly onSaved: () => void;
3031}) {
31 const tokens = useEnvironmentSettings(environmentId, (settings) => settings.github.tokens);
32 const { tokens } = useEnvironmentSettings(environmentId, readGitHubSettings);
3233 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
3334 label: "save GitHub token",
3435 });
......@@ -46,7 +47,10 @@ export function GitHubTokenSettings({
4647 try {
4748 const result = await updateSettings({
4849 environmentId,
49 input: { patch: { github: { tokens: { [target]: token } } } },
50 // The map travels whole: every other saved token as its redaction marker, which keeps it.
51 input: {
52 patch: { sourceControlHosts: { github: { tokens: { ...tokens, [target]: token } } } },
53 },
5054 });
5155 if (result._tag === "Success") {
5256 setDraft("");
apps/web/src/components/settings/ProviderSettingsForm.tsx+30-6
......@@ -19,6 +19,11 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
1919import { Switch } from "../ui/switch";
2020import { Textarea } from "../ui/textarea";
2121import type { ProviderClientDefinition } from "@t3tools/provider-core/client";
22
23/** What the form reads from a definition: an agent provider's, or a source control host's. */
24export type SettingsFormDefinition = Pick<ProviderClientDefinition, "settingsSchema"> & {
25 readonly driverKind?: ProviderClientDefinition["driverKind"];
26};
2227import { SettingsRow } from "./settingsLayout";
2328
2429export interface ProviderSettingsFieldModel {
......@@ -28,6 +33,8 @@ export interface ProviderSettingsFieldModel {
2833 readonly description?: string | undefined;
2934 readonly placeholder?: string | undefined;
3035 readonly clearWhenEmpty: "omit" | "persist";
36 /** Kept in the server's secret store: a saved value arrives as `SECRET_REDACTED` and never shows. */
37 readonly secret?: boolean | undefined;
3138 readonly defaultBooleanValue?: boolean | undefined;
3239 /** Choices for a `select` control. The first entry is the default. */
3340 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
......@@ -63,7 +70,7 @@ function readProviderSettingsFormAnnotation(
6370}
6471
6572function readProviderSettingsFormSchemaAnnotation(
66 definition: ProviderClientDefinition,
73 definition: SettingsFormDefinition,
6774): ProviderSettingsFormSchemaAnnotation {
6875 return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {};
6976}
......@@ -77,7 +84,7 @@ function readFieldBooleanDefault(
7784}
7885
7986export function deriveProviderSettingsFields(
80 definition: ProviderClientDefinition,
87 definition: SettingsFormDefinition,
8188 value?: unknown,
8289): ReadonlyArray<ProviderSettingsFieldModel> {
8390 const isLocalAcp =
......@@ -124,6 +131,7 @@ export function deriveProviderSettingsFields(
124131 ? { placeholder: formAnnotation.placeholder }
125132 : {}),
126133 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
134 ...(formAnnotation.secret ? { secret: true } : {}),
127135 ...(formAnnotation.control === "switch"
128136 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
129137 : {}),
......@@ -247,6 +255,19 @@ function readProviderConfigString(config: unknown, key: string): string {
247255 return typeof value === "string" ? value : "";
248256}
249257
258/**
259 * A secret field never shows its saved value: it starts empty, and its placeholder says whether
260 * one is saved. Saving one is typing a new value; clearing it is the host form's job.
261 */
262function secretFieldInputProps(field: ProviderSettingsFieldModel, config: unknown) {
263 const saved = readProviderConfigString(config, field.key);
264 if (!field.secret) return { value: saved, placeholder: field.placeholder };
265 return {
266 value: "",
267 placeholder: saved.length > 0 ? "Stored secret, enter a new value to replace" : "Not set",
268 };
269}
270
250271function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {
251272 if (config === null || typeof config !== "object") return defaultValue;
252273 const value = (config as Record<string, unknown>)[key];
......@@ -281,7 +302,7 @@ export function nextProviderConfigWithFieldValue(
281302}
282303
283304interface ProviderSettingsFormProps {
284 readonly definition: ProviderClientDefinition;
305 readonly definition: SettingsFormDefinition;
285306 readonly value: unknown;
286307 readonly idPrefix: string;
287308 /**
......@@ -410,9 +431,12 @@ function ProviderSettingsFieldRow({
410431 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
411432 type={field.control === "password" ? "password" : undefined}
412433 autoComplete={field.control === "password" ? "off" : undefined}
413 value={readProviderConfigString(value, field.key)}
414 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
415 placeholder={field.placeholder}
434 {...secretFieldInputProps(field, value)}
435 onCommit={(next) => {
436 // An untouched saved secret commits empty; only a typed value replaces it.
437 if (field.secret && next === "") return;
438 onChange(nextProviderConfigWithFieldValue(value, field, next));
439 }}
416440 spellCheck={false}
417441 />
418442 );
apps/web/src/components/settings/SourceControlHostSettings.tsx created+84
......@@ -0,0 +1,84 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import type { SourceControlClientDefinition } from "@t3tools/client-runtime/source-control-clients";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button } from "../ui/button";
9import { ProviderSettingsForm } from "./ProviderSettingsForm";
10
11/**
12 * One host's settings, drawn from its definition's settings schema and saved under
13 * `sourceControlHosts[kind]`. Secret fields are write-only: the server keeps them in its secret
14 * store and only reports whether each one is set.
15 */
16export function SourceControlHostSettings({
17 environmentId,
18 definition,
19 onSaved,
20}: {
21 readonly environmentId: EnvironmentId;
22 readonly definition: SourceControlClientDefinition & {
23 readonly settings: NonNullable<SourceControlClientDefinition["settings"]>;
24 };
25 readonly onSaved: () => void;
26}) {
27 const saved = useEnvironmentSettings(
28 environmentId,
29 (settings) => settings.sourceControlHosts[definition.kind],
30 );
31 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
32 label: `save ${definition.label} settings`,
33 });
34 const [saving, setSaving] = useState(false);
35 const formDefinition = { settingsSchema: definition.settings };
36 const hasSaved = Object.values(saved ?? {}).some((value) => value !== "");
37
38 const save = async (fields: Record<string, unknown>) => {
39 setSaving(true);
40 try {
41 const result = await updateSettings({
42 environmentId,
43 input: { patch: { sourceControlHosts: { [definition.kind]: fields } } },
44 });
45 if (result._tag === "Success") onSaved();
46 } finally {
47 setSaving(false);
48 }
49 };
50
51 return (
52 // Locked while saving, so an edit made mid-request is not overwritten by the saved value.
53 <fieldset disabled={saving} className="contents">
54 <ProviderSettingsForm
55 definition={formDefinition}
56 value={saved ?? {}}
57 idPrefix={`source-control-${definition.kind}-${environmentId}`}
58 variant="settings"
59 onChange={(next) => {
60 const fields = next ?? {};
61 // Only what changed: each saved secret travels as its redaction marker anyway.
62 const changed = Object.fromEntries(
63 Object.entries(fields).filter(([key, value]) => saved?.[key] !== value),
64 );
65 const cleared = Object.keys(saved ?? {}).filter((key) => !(key in fields));
66 void save({ ...changed, ...Object.fromEntries(cleared.map((key) => [key, ""])) });
67 }}
68 />
69 {hasSaved ? (
70 <div className="flex justify-end pt-2">
71 <Button
72 size="xs"
73 variant="outline"
74 onClick={() =>
75 void save(Object.fromEntries(Object.keys(saved ?? {}).map((key) => [key, ""])))
76 }
77 >
78 Remove
79 </Button>
80 </div>
81 ) : null}
82 </fieldset>
83 );
84}
apps/web/src/components/settings/SourceControlSettings.tsx+23-15
......@@ -46,7 +46,7 @@ import { Switch } from "../ui/switch";
4646import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4747
4848import { GitIcon, JujutsuIcon, type Icon } from "../Icons";
49import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
49import { SourceControlHostSettings } from "./SourceControlHostSettings";
5050import { GitHubAccountSettings } from "./GitHubAccountSettings";
5151import { GitHubTokenSettings } from "./GitHubTokenSettings";
5252import { RedactedSensitiveText } from "./RedactedSensitiveText";
......@@ -59,7 +59,7 @@ import {
5959 SettingsSection,
6060 useSettingsSearchTargetId,
6161} from "./settingsLayout";
62import { searchableSetting } from "./settingsSearch";
62import { searchableSetting, sourceControlHostSettingsSearchId } from "./settingsSearch";
6363import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
6464import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
6565import { sourceControlIcon } from "~/sourceControlPresentation";
......@@ -278,8 +278,7 @@ function DiscoveryItemRow({
278278 useEffect(() => {
279279 if (
280280 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||
281 (item.kind === "bitbucket" &&
282 searchTargetId === searchableSetting("bitbucket-credentials").id) ||
281 searchTargetId === sourceControlHostSettingsSearchId(item.kind) ||
283282 (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id)
284283 ) {
285284 setIsExpanded(true);
......@@ -527,6 +526,22 @@ export function SourceControlSettingsPanel() {
527526 const handleScan = () => {
528527 discovery.refresh();
529528 };
529 /** The host's own settings form, for a host whose definition declares settings. */
530 const hostSettingsPanel = (kind: string) => {
531 const definition = sourceControlClients.find(kind);
532 if (!definition?.settings || environmentId === null) return undefined;
533 return (
534 <SettingsSearchTarget id={sourceControlHostSettingsSearchId(kind)}>
535 <SourceControlHostSettings
536 // Drafts belong to one environment; switching must not carry them over.
537 key={environmentId}
538 environmentId={environmentId}
539 definition={{ ...definition, settings: definition.settings }}
540 onSaved={handleScan}
541 />
542 </SettingsSearchTarget>
543 );
544 };
530545 const scanButton = (
531546 <Tooltip>
532547 <TooltipTrigger
......@@ -591,16 +606,7 @@ export function SourceControlSettingsPanel() {
591606 >
592607 {result.sourceControlProviders.map((item) => (
593608 <DiscoveryItemRow key={`provider:${item.kind}`} item={item}>
594 {item.kind === "bitbucket" ? (
595 <SettingsSearchTarget id={searchableSetting("bitbucket-credentials").id}>
596 <BitbucketCredentialsSettings
597 // Drafts belong to one environment; switching must not carry them over.
598 key={environmentId}
599 environmentId={environmentId}
600 onSaved={handleScan}
601 />
602 </SettingsSearchTarget>
603 ) : item.kind === "github" ? (
609 {item.kind === "github" ? (
604610 <SettingsSearchTarget id={searchableSetting("github-accounts").id}>
605611 <div className="grid gap-6">
606612 {/* Shown even without gh: a saved token is how GitHub works without the CLI. */}
......@@ -619,7 +625,9 @@ export function SourceControlSettingsPanel() {
619625 ) : null}
620626 </div>
621627 </SettingsSearchTarget>
622 ) : undefined}
628 ) : (
629 hostSettingsPanel(item.kind)
630 )}
623631 </DiscoveryItemRow>
624632 ))}
625633 </SettingsSection>
apps/web/src/components/settings/settingsSearch.ts+25-8
......@@ -4,6 +4,7 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti
44import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts";
55import type { EnvironmentId } from "@t3tools/contracts";
66import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
7import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
78import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";
89import { commandLabel } from "./KeybindingsSettings.logic";
910import {
......@@ -132,6 +133,29 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right
132133 };
133134});
134135
136/** Anchor id of a source control host's settings form on the Source Control page. */
137export function sourceControlHostSettingsSearchId(kind: string) {
138 return `source-control-host-${kind}` as const;
139}
140
141/** One result per host whose definition declares settings, searchable by its field titles. */
142const SOURCE_CONTROL_HOST_SEARCH_ITEMS = sourceControlClients.definitions.flatMap((definition) =>
143 definition.settings && definition.kind !== "github"
144 ? [
145 {
146 id: sourceControlHostSettingsSearchId(definition.kind),
147 title: `${definition.label} credentials`,
148 to: "/settings/source-control" as const,
149 searchTerms: [
150 `${definition.label} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`,
151 ],
152 environmentOnly: true,
153 scope: "environment-defaults" as const,
154 },
155 ]
156 : [],
157);
158
135159/**
136160 * Searchable settings and stable destinations, in result order. Rows with a
137161 * dedicated anchor render their id and title via `searchableSetting`; items
......@@ -808,14 +832,7 @@ export const SETTINGS_SEARCH_ITEMS = [
808832 environmentOnly: true,
809833 scope: "environment-defaults",
810834 },
811 {
812 id: "bitbucket-credentials",
813 title: "Bitbucket credentials",
814 to: "/settings/source-control",
815 searchTerms: ["bitbucket atlassian access token api token email credentials sign in"],
816 environmentOnly: true,
817 scope: "environment-defaults",
818 },
835 ...SOURCE_CONTROL_HOST_SEARCH_ITEMS,
819836 {
820837 id: "source-control-writing-style",
821838 title: "Source control writing style",
docs/user/source-control.md+10-8
......@@ -64,18 +64,18 @@ glab auth login
6464
6565### Bitbucket
6666
67Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in:
67Open **Settings → Source Control**, expand **Bitbucket**, and enter one of:
6868
6969- **Access token**: a token created for one repository, project, or workspace. It can only reach
7070 what it was created for.
71- **API token**: an Atlassian API token for your account, used with your account email. It can
71- **Atlassian account email** and **API token**: an Atlassian API token for your account. It can
7272 reach every repository you can. Give it read/write access to repositories and pull requests, plus
7373 user read access (`read:user:bitbucket`).
7474
75Choose **Save**; the change applies right away, and replaces any credential saved with the other
76method. Credentials are saved on the environment's server, so select a remote environment to
77configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose
78**Remove**.
75A field saves when you leave it, and applies right away. The access token wins when both are saved.
76Credentials are saved on the environment's server, so select a remote environment to configure
77it. Saved tokens can't be viewed again; enter a new one to replace it, or choose **Remove** to clear
78everything saved for Bitbucket.
7979
8080If no credentials are saved, T3 Code falls back to these variables in the server's environment.
8181Restart the server after changing them:
......@@ -105,8 +105,10 @@ bun install -g @gitcafe/cli
105105cafe auth login --host https://git.cafe/api
106106```
107107
108Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only
109sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on
108Alternatively, open **Settings → Source Control**, expand **GitCafe**, and enter a token. It is
109kept in the server's secret store, applies right away, and is used before `CAFE_TOKEN` and the
110`cafe` login. Or set `CAFE_TOKEN` in the server's environment and restart it. Both tokens are only
111sent to `git.cafe`; `CAFE_TOKEN` follows `CAFE_HOST` if you set it. Repositories on
110112`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`.
111113
112114Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet.
packages/client-runtime/src/sourceControlClients.ts+15-1
......@@ -9,7 +9,11 @@
99 */
1010import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";
1111import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition";
12import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition";
12import {
13 makeSourceControlClientRegistry,
14 readSourceControlHostSettings,
15} from "@t3tools/source-control-core/client/definition";
16import type { ServerSettings } from "@t3tools/contracts";
1317
1418export {
1519 UNKNOWN_SOURCE_CONTROL_CLIENT,
......@@ -40,3 +44,13 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [
4044export const sourceControlClients = makeSourceControlClientRegistry(
4145 BUILT_IN_SOURCE_CONTROL_CLIENTS,
4246);
47
48export type { GitHubSettings } from "@t3tools/source-control-github/client/definition";
49
50/**
51 * An environment's GitHub host choices and tokens, decoded with GitHub's settings schema. GitHub's
52 * settings panels are its own, since its logins come from `gh` at runtime.
53 */
54export function readGitHubSettings(settings: Pick<ServerSettings, "sourceControlHosts">) {
55 return readSourceControlHostSettings(GitHub.settings, settings.sourceControlHosts.github);
56}
packages/contracts/src/settings.test.ts-7
......@@ -949,13 +949,6 @@ describe("ServerSettingsPatch.providerInstances", () => {
949949});
950950
951951describe("ServerSettingsPatch string normalization", () => {
952 it("lowercases GitHub hosts and defaults them to enabled", () => {
953 const patch = decodeServerSettingsPatch({
954 github: { hosts: { " GitHub.com ": { account: " work " } } },
955 });
956 expect(patch.github?.hosts).toEqual({ "github.com": { account: "work", enabled: true } });
957 });
958
959952 it("trims string settings while decoding patches", () => {
960953 const patch = decodeServerSettingsPatch({
961954 addProjectBaseDirectory: " ~/Development ",
packages/contracts/src/settings.ts+22-56
......@@ -552,6 +552,11 @@ export interface ProviderSettingsFormAnnotation {
552552 readonly placeholder?: string | undefined;
553553 readonly hidden?: boolean | undefined;
554554 readonly clearWhenEmpty?: "omit" | "persist" | undefined;
555 /**
556 * The value is a credential: the server keeps it in its secret store and clients only learn
557 * whether one is saved. Source control host settings honor it; provider settings do not yet.
558 */
559 readonly secret?: boolean | undefined;
555560 /** Choices for a `select` control. The first entry is the default. */
556561 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
557562}
......@@ -805,49 +810,20 @@ export const UsageLimitSourceConfig = Schema.Struct({
805810});
806811export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
807812
808/**
809 * Bitbucket API credentials for this environment, used before the
810 * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's
811 * secret store; settings and clients only see a redaction marker when one is
812 * set. The access token wins when both kinds are configured.
813 */
814export const BitbucketSettings = Schema.Struct({
815 email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
816 accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
817 apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
818});
819export type BitbucketSettings = typeof BitbucketSettings.Type;
820
821/**
822 * Per-host choices for the GitHub CLI's logins. `account` pins one of the logins
823 * `gh` holds for the host instead of its active one; a disabled host gets no
824 * credential at all. A token saved here wins over `GH_TOKEN` and friends, which win over `gh`.
825 */
826813/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */
827814export const GitHubHost = TrimmedNonEmptyString.pipe(
828815 Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),
829816);
830817
831export const GitHubHostSettings = Schema.Struct({
832 account: Schema.optionalKey(TrimmedNonEmptyString),
833 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
834});
835export type GitHubHostSettings = typeof GitHubHostSettings.Type;
836
837export const GitHubSettings = Schema.Struct({
838 /** Keyed by lowercased host, for example `github.com`. */
839 hosts: Schema.Record(GitHubHost, GitHubHostSettings).pipe(
840 Schema.withDecodingDefault(Effect.succeed({})),
841 ),
842 /**
843 * A token per host, used before `GH_TOKEN` and `gh`. The server keeps each one in its secret
844 * store; settings and clients only ever see a redaction marker for a saved token.
845 */
846 tokens: Schema.Record(GitHubHost, TrimmedString).pipe(
847 Schema.withDecodingDefault(Effect.succeed({})),
848 ),
849});
850export type GitHubSettings = typeof GitHubSettings.Type;
818/**
819 * One source control host's saved settings. Each host package declares the fields it reads in
820 * its client definition's settings schema and decodes this blob with it; the environment stores
821 * it whole, so a host this build does not ship keeps its settings. A field annotated as a secret
822 * holds a string, or a string per server host, whose value lives in the server's secret store:
823 * settings and clients only see a redaction marker for a saved one.
824 */
825export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown);
826export type SourceControlHostSettings = typeof SourceControlHostSettings.Type;
851827
852828export const ObservabilitySettings = Schema.Struct({
853829 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
......@@ -1278,8 +1254,10 @@ export const ServerSettings = Schema.Struct({
12781254 Schema.withDecodingDefault(Effect.succeed({})),
12791255 ),
12801256 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1281 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1282 github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1257 /** Keyed by source control host kind, such as `gitcafe`; see `SourceControlHostSettings`. */
1258 sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).pipe(
1259 Schema.withDecodingDefault(Effect.succeed({})),
1260 ),
12831261 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
12841262 // this build cannot decode round-trip untouched, as provider instances do.
12851263 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
......@@ -1523,24 +1501,12 @@ export const ServerSettingsPatch = Schema.Struct({
15231501 otlpLogsUrl: Schema.optionalKey(TrimmedString),
15241502 }),
15251503 ),
1526 /** An empty token clears it; an omitted one keeps what the server has. */
1527 bitbucket: Schema.optionalKey(
1528 Schema.Struct({
1529 email: Schema.optionalKey(TrimmedString),
1530 accessToken: Schema.optionalKey(TrimmedString),
1531 apiToken: Schema.optionalKey(TrimmedString),
1532 }),
1533 ),
15341504 /**
1535 * `hosts` replaces the whole map, so an omitted host or account clears it. `tokens` merges per
1536 * host: an empty token removes that host's token, the redaction marker keeps it.
1505 * Merges per host: a patched field replaces the saved one, so a client sends a per-server-host
1506 * map whole, with the redaction marker for each saved secret it keeps. An empty or omitted
1507 * secret is removed from the secret store.
15371508 */
1538 github: Schema.optionalKey(
1539 Schema.Struct({
1540 hosts: Schema.optionalKey(Schema.Record(GitHubHost, GitHubHostSettings)),
1541 tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)),
1542 }),
1543 ),
1509 sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)),
15441510 // Whole-map replacement for the new instance config. Patching individual
15451511 // entries is intentionally out of scope: the map is small, and partial
15461512 // patches risk leaving driver-specific config in a half-merged state.
packages/shared/src/serverSettings.test.ts+11-8
......@@ -42,16 +42,19 @@ describe("serverSettings helpers", () => {
4242 logsAfterDays: 30,
4343 });
4444 });
45 it("replaces GitHub host choices so a cleared account pin does not survive", () => {
45 it("replaces a source control host's patched field so a cleared account pin does not survive", () => {
4646 const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
47 github: { hosts: { "github.com": { account: "work", enabled: true } } },
47 sourceControlHosts: {
48 github: { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} },
49 },
50 });
51 const unpinned = applyServerSettingsPatch(pinned, {
52 sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } },
53 });
54 expect(unpinned.sourceControlHosts.github).toEqual({
55 hosts: { "github.com": { enabled: false } },
56 tokens: {},
4857 });
49 expect(pinned.github.hosts).toEqual({ "github.com": { account: "work", enabled: true } });
50 expect(
51 applyServerSettingsPatch(pinned, {
52 github: { hosts: { "github.com": { enabled: false } } },
53 }).github.hosts,
54 ).toEqual({ "github.com": { enabled: false } });
5558 });
5659 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
5760 const host = { id: "mini", label: "Mac mini", target: "mini" };
packages/shared/src/serverSettings.ts+14-3
......@@ -267,6 +267,7 @@ export function applyServerSettingsPatch(
267267 worktreeCleanup: worktreeCleanupPatch,
268268 // Merged per entry below; its `null` removals must not reach deepMerge.
269269 usageLimitSources: usageLimitSourcesPatch,
270 sourceControlHosts: sourceControlHostsPatch,
270271 usagePriceOverrides: usagePriceOverridesPatch,
271272 usageModelAliases: usageModelAliasesPatch,
272273 // Entry replacement: deepMerge would keep keys the client meant to clear.
......@@ -367,9 +368,19 @@ export function applyServerSettingsPatch(
367368 ],
368369 }
369370 : {}),
370 // Host replacement: deepMerge would keep a cleared account pin.
371 ...(patch.github?.hosts !== undefined
372 ? { github: { ...next.github, hosts: patch.github.hosts } }
371 // Per host, a patched field replaces the saved one: deepMerge would keep a cleared account pin.
372 ...(sourceControlHostsPatch !== undefined
373 ? {
374 sourceControlHosts: {
375 ...current.sourceControlHosts,
376 ...Object.fromEntries(
377 Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [
378 kind,
379 { ...current.sourceControlHosts[kind], ...fields },
380 ]),
381 ),
382 },
383 }
373384 : {}),
374385 ...(projectSettingsOverridesPatch !== undefined
375386 ? {
packages/source-control-bitbucket/src/client/definition.ts+43-1
......@@ -3,7 +3,13 @@
33 *
44 * @module source-control-bitbucket/client/definition
55 */
6import { SourceControlProviderKind } from "@t3tools/contracts";
6import {
7 makeProviderSettingsSchema,
8 SourceControlProviderKind,
9 TrimmedString,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
713import {
814 defineSourceControlClient,
915 isChangeRequestInProjectRepository,
......@@ -16,6 +22,41 @@ const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1622
1723const KIND = SourceControlProviderKind.make("bitbucket");
1824
25const text = (annotations: Schema.Annotations.Key<string>) =>
26 TrimmedString.pipe(
27 Schema.withDecodingDefault(Effect.succeed("")),
28 Schema.annotateKey(annotations),
29 );
30
31/**
32 * Bitbucket API credentials, used before the `T3CODE_BITBUCKET_*` environment variables: an
33 * access token, or an Atlassian account email with an API token. The access token wins when both
34 * are saved.
35 */
36export const settings = makeProviderSettingsSchema(
37 {
38 accessToken: text({
39 title: "Access token",
40 description:
41 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
42 providerSettingsForm: { control: "password", secret: true },
43 }),
44 email: text({
45 title: "Atlassian account email",
46 description: "With an API token instead of an access token.",
47 providerSettingsForm: { placeholder: "you@example.com" },
48 }),
49 apiToken: text({
50 title: "API token",
51 description:
52 "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.",
53 providerSettingsForm: { control: "password", secret: true },
54 }),
55 },
56 { order: ["accessToken", "email", "apiToken"] },
57);
58export type BitbucketSettings = typeof settings.Type;
59
1960export const definition = defineSourceControlClient({
2061 kind: KIND,
2162 label: "Bitbucket",
......@@ -54,4 +95,5 @@ export const definition = defineSourceControlClient({
5495 canReadChangeRequestOnHost: (identity, link) =>
5596 isChangeRequestOnProjectHost(KIND, identity, link),
5697 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),
98 settings,
5799});
packages/source-control-bitbucket/src/server/BitbucketApi.ts+18-5
......@@ -7,10 +7,8 @@ import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
88import * as Schema from "effect/Schema";
99import {
10 DEFAULT_SERVER_SETTINGS,
1110 NonNegativeInt,
1211 TrimmedNonEmptyString,
13 type BitbucketSettings,
1412 type SourceControlProviderAuth,
1513 type SourceControlRepositoryCloneUrls,
1614 type SourceControlRepositoryVisibility,
......@@ -31,6 +29,9 @@ import {
3129import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
3230import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
3331import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
32import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
33
34import * as BitbucketClient from "../client/definition.ts";
3435import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";
3536
3637const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
......@@ -569,7 +570,7 @@ function credentialFrom(input: {
569570 * stay as a fallback. Within each source the access token wins.
570571 */
571572function resolveCredential(
572 settings: BitbucketSettings,
573 settings: BitbucketClient.BitbucketSettings,
573574 env: Config.Success<typeof BitbucketApiEnvConfig>,
574575): BitbucketCredential | null {
575576 return (
......@@ -664,12 +665,24 @@ export const make = Effect.gen(function* () {
664665
665666 // Read on every request so credentials saved in settings apply without a restart.
666667 const currentCredential = host.settings.get.pipe(
667 Effect.map((settings) => resolveCredential(settings.bitbucket, config)),
668 Effect.map((settings) =>
669 resolveCredential(
670 readSourceControlHostSettings(
671 BitbucketClient.settings,
672 settings.sourceControlHosts[BitbucketClient.definition.kind],
673 ),
674 config,
675 ),
676 ),
668677 Effect.catch((error) =>
669678 // No cause: a settings decode error can quote a hand-edited token.
670679 Effect.logWarning("failed to read Bitbucket credentials from settings", {
671680 operation: error.operation,
672 }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))),
681 }).pipe(
682 Effect.as(
683 resolveCredential(readSourceControlHostSettings(BitbucketClient.settings, {}), config),
684 ),
685 ),
673686 ),
674687 );
675688
packages/source-control-core/src/client/definition.ts+43
......@@ -19,6 +19,7 @@ import {
1919 SourceControlProviderKind,
2020} from "@t3tools/contracts";
2121import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
22import * as Schema from "effect/Schema";
2223
2324/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */
2425export interface ChangeRequestTerminology {
......@@ -140,8 +141,20 @@ export interface SourceControlClientDefinition {
140141 readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null;
141142 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */
142143 readonly isChangeRequestUrl: (url: string) => boolean;
144 /**
145 * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered
146 * as a form in Source Control settings and read by the host's server package. Fields carry
147 * `providerSettingsForm` annotations, as agent provider settings do; one marked `secret` is
148 * kept in the server's secret store. Omitted for a host with nothing to configure.
149 */
150 readonly settings?: SourceControlHostSettingsSchema;
143151}
144152
153/** A host's settings struct. Its fields are strings, or a secret string per server host. */
154export type SourceControlHostSettingsSchema = {
155 readonly fields: Readonly<Record<string, Schema.Top>>;
156} & Schema.Decoder<object>;
157
145158/**
146159 * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number.
147160 * Matching the parsed path keeps a query or fragment that mentions another host's route out.
......@@ -293,3 +306,33 @@ export function makeSourceControlClientRegistry(
293306 definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host",
294307 };
295308}
309
310/**
311 * A host's saved settings decoded with its schema. A blob that no longer decodes, such as one a
312 * newer build wrote, reads as the schema's defaults rather than failing the host.
313 */
314export function readSourceControlHostSettings<S extends SourceControlHostSettingsSchema>(
315 schema: S,
316 saved: unknown,
317): S["Type"] {
318 const decode = Schema.decodeUnknownOption(schema as Schema.Decoder<S["Type"]>);
319 const decoded = decode(saved ?? {});
320 if (decoded._tag === "Some") return decoded.value;
321 const fallback = decode({});
322 if (fallback._tag === "Some") return fallback.value;
323 throw new Error("A source control host settings schema must decode an empty object.");
324}
325
326/**
327 * The settings fields a host keeps in the server's secret store: those annotated
328 * `providerSettingsForm.secret`. A secret field holds a string, or a string per server host.
329 */
330export function secretSourceControlHostSettingsFields(
331 schema: SourceControlHostSettingsSchema,
332): ReadonlyArray<string> {
333 return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => {
334 const annotations =
335 Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema);
336 return annotations?.providerSettingsForm?.secret ? [field] : [];
337 });
338}
packages/source-control-gitcafe/src/client/definition.ts+22-1
......@@ -3,7 +3,13 @@
33 *
44 * @module source-control-gitcafe/client/definition
55 */
6import { SourceControlProviderKind } from "@t3tools/contracts";
6import {
7 makeProviderSettingsSchema,
8 SourceControlProviderKind,
9 TrimmedString,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
713import {
814 defineSourceControlClient,
915 isChangeRequestInProjectRepository,
......@@ -16,6 +22,20 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);
1622
1723const KIND = SourceControlProviderKind.make("gitcafe");
1824
25/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */
26export const settings = makeProviderSettingsSchema({
27 token: TrimmedString.pipe(
28 Schema.withDecodingDefault(Effect.succeed("")),
29 Schema.annotateKey({
30 title: "Token",
31 description:
32 "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.",
33 providerSettingsForm: { control: "password", secret: true },
34 }),
35 ),
36});
37export type GitCafeSettings = typeof settings.Type;
38
1939export const definition = defineSourceControlClient({
2040 kind: KIND,
2141 label: "GitCafe",
......@@ -64,4 +84,5 @@ export const definition = defineSourceControlClient({
6484 return false;
6585 }
6686 },
87 settings,
6788});
packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts+38
......@@ -78,6 +78,44 @@ describe("GitCafeCredentials", () => {
7878 }).pipe(Effect.provide(layer));
7979 });
8080
81 it.effect("uses a token saved in Settings for git.cafe only, ahead of CAFE_TOKEN", () => {
82 const calls: Array<SourceControlHost.SourceControlProcessInput> = [];
83 const layer = GitCafeCredentials.layer.pipe(
84 Layer.provideMerge(
85 TestSourceControlHost.layer({
86 process: {
87 run: (input) => {
88 calls.push(input);
89 return Effect.succeed(processOutput("password=gct_staging\n"));
90 },
91 },
92 }),
93 ),
94 Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })),
95 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
96 );
97 return Effect.gen(function* () {
98 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
99 const settings = yield* TestSourceControlHost.TestSourceControlHostSettings;
100 yield* settings.update({ sourceControlHosts: { gitcafe: { token: "saved-token" } } });
101
102 const saved = yield* credentials.get("git.cafe");
103 assert.strictEqual(Redacted.value(saved.token), "saved-token");
104 assert.strictEqual(saved.source, "settings");
105 assert.deepStrictEqual(yield* credentials.cliEnv("git.cafe"), { CAFE_TOKEN: "saved-token" });
106 // Staging never receives the production token saved in Settings.
107 assert.deepStrictEqual(yield* credentials.cliEnv("staging.git.cafe"), {});
108 assert.strictEqual(
109 Redacted.value((yield* credentials.get("staging.git.cafe")).token),
110 "gct_staging",
111 );
112
113 // Removing it applies at once: the environment token takes over again.
114 yield* settings.update({ sourceControlHosts: { gitcafe: { token: "" } } });
115 assert.strictEqual((yield* credentials.get("git.cafe")).source, "env");
116 }).pipe(Effect.provide(layer));
117 });
118
81119 it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => {
82120 const { layer, calls } = harness(() =>
83121 Effect.succeed(
packages/source-control-gitcafe/src/server/GitCafeCredentials.ts+50-10
......@@ -10,6 +10,9 @@ import * as Redacted from "effect/Redacted";
1010import * as Schema from "effect/Schema";
1111import * as HostProcess from "@t3tools/shared/HostProcess";
1212import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
13import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
14
15import * as GitCafeClient from "../client/definition.ts";
1316
1417/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */
1518const TOKEN_TTL = Duration.minutes(5);
......@@ -19,7 +22,7 @@ const MISSING_TTL = Duration.seconds(10);
1922export interface GitCafeCredential {
2023 readonly host: string;
2124 readonly token: Redacted.Redacted<string>;
22 readonly source: "env" | "cafe";
25 readonly source: "settings" | "env" | "cafe";
2326}
2427
2528/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */
......@@ -66,10 +69,17 @@ export class GitCafeCredentials extends Context.Service<
6669 ) => Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError>;
6770 /** Drops the held token after GitCafe refused it, so the next read asks its source again. */
6871 readonly invalidate: (host: string) => Effect.Effect<void>;
72 /**
73 * Environment for a `cafe` run against the host: the token saved in Settings as `CAFE_TOKEN`,
74 * which `cafe` uses before its own login, or nothing when none is saved.
75 */
76 readonly cliEnv: (host: string) => Effect.Effect<Readonly<Record<string, string>>>;
6977 }
7078>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {}
7179
7280const normalizeHost = (host: string) => host.trim().toLowerCase();
81/** The host `cafe` and a token saved in Settings target unless told otherwise. */
82const DEFAULT_HOST = "git.cafe";
7383
7484/**
7585 * `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(
8292 const configured = env.CAFE_HOST?.trim();
8393 const configuredHost = configured
8494 ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host
85 : "git.cafe";
95 : DEFAULT_HOST;
8696 if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null;
8797 return env.CAFE_TOKEN?.trim() || null;
8898}
......@@ -97,7 +107,8 @@ function credentialPassword(output: string): string | null {
97107
98108/** @public Service construction is part of the canonical Effect module API. */
99109export const make = Effect.gen(function* () {
100 const { process } = yield* SourceControlHost.SourceControlHost;
110 const sourceControlHost = yield* SourceControlHost.SourceControlHost;
111 const { process } = sourceControlHost;
101112 const environment = yield* HostProcess.Environment;
102113 const workingDirectory = yield* HostProcess.WorkingDirectory;
103114
......@@ -134,14 +145,27 @@ export const make = Effect.gen(function* () {
134145 ),
135146 );
136147
137 const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) {
148 /** A token saved in Settings, for production git.cafe only; read fresh on every lookup. */
149 const fromSettings = (host: string) =>
150 normalizeHost(host) !== DEFAULT_HOST
151 ? Effect.succeed(null)
152 : sourceControlHost.settings.get.pipe(
153 Effect.map(
154 (settings) =>
155 readSourceControlHostSettings(
156 GitCafeClient.settings,
157 settings.sourceControlHosts[GitCafeClient.definition.kind],
158 ).token || null,
159 ),
160 Effect.orElseSucceed(() => null),
161 );
162
163 const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (
164 host: string,
165 ): Effect.fn.Return<GitCafeCredential, GitCafeCredentialUnavailableError> {
138166 const fromEnv = environmentToken(host, environment);
139167 const token = fromEnv ?? (yield* fromCafe(host));
140 return {
141 host,
142 token: Redacted.make(token),
143 source: fromEnv !== null ? "env" : "cafe",
144 } satisfies GitCafeCredential;
168 return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "cafe" };
145169 });
146170
147171 const cache = yield* Cache.makeWith(lookup, {
......@@ -158,8 +182,24 @@ export const make = Effect.gen(function* () {
158182 });
159183
160184 return GitCafeCredentials.of({
161 get: (host) => Cache.get(cache, normalizeHost(host)),
185 // A saved token is read fresh, so saving or removing one applies at once; the cache only
186 // spares asking `cafe` again.
187 get: (host) =>
188 fromSettings(host).pipe(
189 Effect.flatMap(
190 (saved): Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError> =>
191 saved === null
192 ? Cache.get(cache, normalizeHost(host))
193 : Effect.succeed({
194 host: normalizeHost(host),
195 token: Redacted.make(saved),
196 source: "settings",
197 }),
198 ),
199 ),
162200 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),
201 cliEnv: (host) =>
202 fromSettings(host).pipe(Effect.map((saved) => (saved === null ? {} : { CAFE_TOKEN: saved }))),
163203 });
164204});
165205
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts+5-13
......@@ -42,16 +42,8 @@ const failure = (code: string, status: number | null) => ({
4242});
4343
4444describe("GitCafeSourceControlProvider", () => {
45 it("probes the cafe CLI against production", () => {
46 const { discovery } = GitCafeSourceControlProvider;
47 assert.strictEqual(discovery.kind, "gitcafe");
48 assert.strictEqual(discovery.executable, "cafe");
49 assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]);
50 assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]);
51 });
52
5345 it("reads a signed-in account and keeps transient failures apart from sign-out", () => {
54 const { parseAuth } = GitCafeSourceControlProvider.discovery;
46 const { parseAuth } = GitCafeSourceControlProvider;
5547 const signedIn = parseAuth(
5648 authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }),
5749 );
......@@ -115,7 +107,7 @@ describe("GitCafeSourceControlProvider", () => {
115107 }).pipe(
116108 Effect.provide(
117109 GitCafeApi.layer.pipe(
118 Layer.provide(GitCafeCredentials.layer),
110 Layer.provideMerge(GitCafeCredentials.layer),
119111 Layer.provideMerge(TestSourceControlHost.layer()),
120112 Layer.provide(
121113 Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }),
......@@ -162,7 +154,7 @@ describe("GitCafeSourceControlProvider", () => {
162154 }).pipe(
163155 Effect.provide(
164156 GitCafeApi.layer.pipe(
165 Layer.provide(GitCafeCredentials.layer),
157 Layer.provideMerge(GitCafeCredentials.layer),
166158 Layer.provideMerge(
167159 TestSourceControlHost.layer({
168160 git: {
......@@ -244,7 +236,7 @@ describe("GitCafeSourceControlProvider", () => {
244236 }).pipe(
245237 Effect.provide(
246238 GitCafeApi.layer.pipe(
247 Layer.provide(GitCafeCredentials.layer),
239 Layer.provideMerge(GitCafeCredentials.layer),
248240 Layer.provideMerge(
249241 TestSourceControlHost.layer({
250242 git: {
......@@ -304,7 +296,7 @@ describe("GitCafeSourceControlProvider", () => {
304296 }).pipe(
305297 Effect.provide(
306298 GitCafeApi.layer.pipe(
307 Layer.provide(GitCafeCredentials.layer),
299 Layer.provideMerge(GitCafeCredentials.layer),
308300 Layer.provideMerge(
309301 TestSourceControlHost.layer({
310302 process: {
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts+46-11
......@@ -11,13 +11,16 @@ import {
1111 type ChangeRequest,
1212} from "@t3tools/contracts";
1313import {
14 probeSourceControlProvider,
1415 providerAuth,
1516 type SourceControlCliDiscoverySpec,
17 type SourceControlManagedCliDiscoverySpec,
1618} from "@t3tools/source-control-core/server/discovery";
1719import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
1820import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
1921
2022import * as GitCafeApi from "./GitCafeApi.ts";
23import * as GitCafeCredentials from "./GitCafeCredentials.ts";
2124import * as GitCafeHosts from "./gitCafeHosts.ts";
2225
2326/** Discovery reports the production account; repository operations pick their own host. */
......@@ -58,7 +61,7 @@ const cliFailure = (output: string) =>
5861 .map((line) => decodeFailure(line.trim()))
5962 .find(Result.isSuccess)?.success.error;
6063
61export const discovery = {
64const cliDiscovery = {
6265 type: "cli",
6366 kind: SourceControlProviderKind.make("gitcafe"),
6467 label: "GitCafe",
......@@ -90,9 +93,35 @@ export const discovery = {
9093 detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`,
9194 });
9295 },
93 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.`,
96 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.`,
9497} satisfies SourceControlCliDiscoverySpec;
9598
99/** `cafe auth status`'s reading, exposed for tests. */
100export const parseAuth = cliDiscovery.parseAuth;
101
102/** Probes `cafe` as the CLI spec does, with a token saved in Settings handed to it. */
103export const makeDiscovery = Effect.gen(function* () {
104 const { process } = yield* SourceControlHost.SourceControlHost;
105 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
106 return {
107 type: "managed-cli",
108 kind: cliDiscovery.kind,
109 label: cliDiscovery.label,
110 installHint: cliDiscovery.installHint,
111 probe: (cwd: string) =>
112 credentials.cliEnv(AUTH_HOST).pipe(
113 Effect.flatMap((env) =>
114 probeSourceControlProvider({
115 cwd,
116 spec: cliDiscovery,
117 process: { run: (input) => process.run({ ...input, env: { ...input.env, ...env } }) },
118 }),
119 ),
120 ),
121 refineUnknownRemote: () => Effect.succeed(null),
122 } satisfies SourceControlManagedCliDiscoverySpec;
123});
124
96125const Repository = Schema.Struct({
97126 name: TrimmedNonEmptyString,
98127 defaultBranch: Schema.NullOr(TrimmedNonEmptyString),
......@@ -148,6 +177,7 @@ const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?
148177export const make = Effect.gen(function* () {
149178 const api = yield* GitCafeApi.GitCafeApi;
150179 const host = yield* SourceControlHost.SourceControlHost;
180 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
151181
152182 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>
153183 new SourceControlProviderError({
......@@ -174,15 +204,20 @@ export const make = Effect.gen(function* () {
174204 ),
175205 );
176206 const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray<string>) =>
177 host.process
178 .run({
179 operation: `GitCafeSourceControlProvider.${operation}`,
180 command: "cafe",
181 cwd,
182 args: [...cliArgs(target.host), ...args],
183 env: { CAFE_OUTPUT: "json" },
184 timeoutMs: 30_000,
185 })
207 credentials
208 .cliEnv(target.host)
209 .pipe(
210 Effect.flatMap((env) =>
211 host.process.run({
212 operation: `GitCafeSourceControlProvider.${operation}`,
213 command: "cafe",
214 cwd,
215 args: [...cliArgs(target.host), ...args],
216 env: { CAFE_OUTPUT: "json", ...env },
217 timeoutMs: 30_000,
218 }),
219 ),
220 )
186221 .pipe(
187222 Effect.mapError((cause) =>
188223 error(
packages/source-control-gitcafe/src/server/driver.ts+1-1
......@@ -15,7 +15,7 @@ export const driver = defineSourceControlDriver({
1515 kind: SourceControlProviderKind.make("gitcafe"),
1616 make: Effect.all({
1717 sourceControl: GitCafeSourceControlProvider.make,
18 discovery: Effect.succeed(GitCafeSourceControlProvider.discovery),
18 discovery: GitCafeSourceControlProvider.makeDiscovery,
1919 pullRequests: GitCafePullRequestProvider.make,
2020 }),
2121});
packages/source-control-github/src/client/definition.ts+35-1
......@@ -3,7 +3,16 @@
33 *
44 * @module source-control-github/client/definition
55 */
6import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts";
6import {
7 GitHubHost,
8 makeProviderSettingsSchema,
9 pullRequestHostOf,
10 SourceControlProviderKind,
11 TrimmedNonEmptyString,
12 TrimmedString,
13} from "@t3tools/contracts";
14import * as Effect from "effect/Effect";
15import * as Schema from "effect/Schema";
716import {
817 defineSourceControlClient,
918 isChangeRequestInProjectRepository,
......@@ -17,6 +26,30 @@ const CHANGE_REQUEST_REFERENCE =
1726
1827const KIND = SourceControlProviderKind.make("github");
1928
29export const GitHubHostChoice = Schema.Struct({
30 account: Schema.optionalKey(TrimmedNonEmptyString),
31 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
32});
33export type GitHubHostChoice = typeof GitHubHostChoice.Type;
34
35/**
36 * Per-host choices, keyed by lowercased host such as `github.com`. `hosts` pins one of the logins
37 * `gh` holds for a host instead of its active one, or turns the host off; `tokens` holds a token
38 * per host, used before `GH_TOKEN` and friends, which win over `gh`. Source Control settings draws
39 * both with GitHub's own panels, since the logins come from `gh` at runtime.
40 */
41export const settings = makeProviderSettingsSchema({
42 hosts: Schema.Record(GitHubHost, GitHubHostChoice).pipe(
43 Schema.withDecodingDefault(Effect.succeed({})),
44 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
45 ),
46 tokens: Schema.Record(GitHubHost, TrimmedString).pipe(
47 Schema.withDecodingDefault(Effect.succeed({})),
48 Schema.annotateKey({ providerSettingsForm: { hidden: true, secret: true } }),
49 ),
50});
51export type GitHubSettings = typeof settings.Type;
52
2053export const definition = defineSourceControlClient({
2154 kind: KIND,
2255 label: "GitHub",
......@@ -60,4 +93,5 @@ export const definition = defineSourceControlClient({
6093 canReadChangeRequestOnHost: (identity, link) =>
6194 isChangeRequestOnProjectHost(KIND, identity, link),
6295 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"),
96 settings,
6397});
packages/source-control-github/src/server/GitHubCredentials.test.ts+8-4
......@@ -57,7 +57,7 @@ function harness(
5757 Layer.provideMerge(
5858 TestSourceControlHost.layer({
5959 settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
60 github: { hosts: hostSettings(hosts), tokens },
60 sourceControlHosts: { github: { hosts: hostSettings(hosts), tokens } },
6161 }),
6262 process: { run },
6363 }),
......@@ -128,16 +128,20 @@ describe("GitHubCredentials", () => {
128128 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
129129
130130 yield* settings.update({
131 github: { hosts: { "github.com": { account: "work", enabled: true } } },
131 sourceControlHosts: {
132 github: { hosts: { "github.com": { account: "work", enabled: true } } },
133 },
132134 });
133135 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work");
134136
135 yield* settings.update({ github: { hosts: { "github.com": { enabled: false } } } });
137 yield* settings.update({
138 sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } },
139 });
136140 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(
137141 "GitHubHostDisabledError",
138142 );
139143
140 yield* settings.update({ github: { hosts: {} } });
144 yield* settings.update({ sourceControlHosts: { github: { hosts: {} } } });
141145 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
142146 // The unpinned token stayed cached; only the newly pinned account cost a gh call.
143147 expect(calls).toHaveLength(2);
packages/source-control-github/src/server/GitHubCredentials.ts+12-2
......@@ -14,6 +14,10 @@ import * as Schema from "effect/Schema";
1414import * as HostProcess from "@t3tools/shared/HostProcess";
1515
1616import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
17import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
18import type { ServerSettings } from "@t3tools/contracts";
19
20import * as GitHubClient from "../client/definition.ts";
1721
1822/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */
1923const TOKEN_TTL = Duration.minutes(5);
......@@ -176,17 +180,23 @@ export const make = Effect.gen(function* () {
176180 ),
177181 );
178182
183 const readSettings = (settings: ServerSettings) =>
184 readSourceControlHostSettings(
185 GitHubClient.settings,
186 settings.sourceControlHosts[GitHubClient.definition.kind],
187 );
188
179189 /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */
180190 const hostChoice = (host: string) =>
181191 sourceControlHost.settings.get.pipe(
182 Effect.map((settings) => settings.github.hosts[host]),
192 Effect.map((settings) => readSettings(settings).hosts[host]),
183193 Effect.orElseSucceed(() => undefined),
184194 );
185195
186196 /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */
187197 const savedToken = (host: string) =>
188198 sourceControlHost.settings.get.pipe(
189 Effect.map((settings) => settings.github.tokens[host]?.trim() || null),
199 Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null),
190200 Effect.orElseSucceed(() => null),
191201 );
192202
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+10-6
......@@ -10,11 +10,9 @@ import * as RequestResolver from "effect/RequestResolver";
1010import * as Result from "effect/Result";
1111import * as Schema from "effect/Schema";
1212import {
13 DEFAULT_SERVER_SETTINGS,
1413 SourceControlProviderError,
1514 TrimmedNonEmptyString,
1615 type ChangeRequest,
17 type GitHubSettings,
1816 type SourceControlProviderDiscoveryItem,
1917 type SourceControlRepositoryCloneUrls,
2018} from "@t3tools/contracts";
......@@ -55,6 +53,9 @@ import {
5553 type SourceControlManagedCliDiscoverySpec,
5654} from "@t3tools/source-control-core/server/discovery";
5755import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
56import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition";
57
58import * as GitHubClient from "../client/definition.ts";
5859
5960const decodeLinkSubject = Schema.decodeUnknownEffect(
6061 Schema.fromJsonString(
......@@ -85,7 +86,7 @@ function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
8586 */
8687export function parseGitHubAuth(
8788 input: SourceControlAuthProbeInput,
88 settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github,
89 settings: GitHubClient.GitHubSettings = readSourceControlHostSettings(GitHubClient.settings, {}),
8990) {
9091 const output = combinedAuthOutput(input);
9192 const authStatus = parseGitHubAuthStatus(input.stdout);
......@@ -202,9 +203,12 @@ export const makeDiscovery = Effect.gen(function* () {
202203 label: discovery.label,
203204 installHint: discovery.installHint,
204205 probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) {
205 const settings = yield* sourceControlHost.settings.get.pipe(
206 Effect.map((current) => current.github),
207 Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github),
206 const settings = readSourceControlHostSettings(
207 GitHubClient.settings,
208 yield* sourceControlHost.settings.get.pipe(
209 Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]),
210 Effect.orElseSucceed(() => undefined),
211 ),
208212 );
209213 const cli = yield* probeSourceControlProvider({
210214 cwd,
packages/source-control-github/src/server/gitHubAuthStatus.ts+1-1
......@@ -1,4 +1,4 @@
1import type { GitHubSettings } from "@t3tools/contracts";
1import type { GitHubSettings } from "../client/definition.ts";
22import * as Option from "effect/Option";
33import * as Schema from "effect/Schema";
44