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";...@@ -26,6 +26,7 @@ import * as Stream from "effect/Stream";
26import * as TestClock from "effect/testing/TestClock";26import * as TestClock from "effect/testing/TestClock";
27import * as SqlClient from "effect/sql/SqlClient";27import * as SqlClient from "effect/sql/SqlClient";
28import * as ServerSecretStore from "./auth/ServerSecretStore.ts";28import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
29import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts";
29import * as ServerConfig from "./config.ts";30import * as ServerConfig from "./config.ts";
30import * as SqlitePersistence from "./persistence/Sqlite.ts";31import * as SqlitePersistence from "./persistence/Sqlite.ts";
31import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";32import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
...@@ -1418,7 +1419,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1418,7 +1419,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1418 );1419 );
14191420
1420 it.effect(1421 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",
1422 () =>1423 () =>
1423 Effect.gen(function* () {1424 Effect.gen(function* () {
1424 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1425 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
...@@ -1427,139 +1428,134 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1427,139 +1428,134 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1427 const fileSystem = yield* FileSystem.FileSystem;1428 const fileSystem = yield* FileSystem.FileSystem;
14281429
1429 const saved = yield* serverSettings.updateSettings({1430 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 },
1431 });1435 });
1432 assert.deepEqual(saved.bitbucket, {1436 assert.deepEqual(saved.sourceControlHosts.bitbucket, {
1433 email: "me@example.com",1437 email: "me@example.com",
1434 accessToken: "bb-access",1438 accessToken: "bb-access",
1435 apiToken: "bb-api",
1436 });1439 });
1437
1438 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);1440 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1439 assert.notInclude(raw, "bb-access");1441 for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret);
1440 assert.notInclude(raw, "bb-api");
1441 assert.include(raw, "me@example.com");1442 assert.include(raw, "me@example.com");
14421443
1443 const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket;1444 const forClient =
1444 assert.equal(forClient.email, "me@example.com");1445 ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts;
1445 assert.notInclude(forClient.accessToken, "bb-access");1446 assert.equal(forClient.bitbucket?.email, "me@example.com");
1446 assert.notInclude(forClient.apiToken, "bb-api");1447 assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access");
1447 assert.isAbove(forClient.accessToken.length, 0);1448 assert.isAbove(String(forClient.bitbucket?.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;
14781449
1479 const saved = yield* serverSettings.updateSettings({1450 // Echoing the redacted values back keeps them; other fields change on their own.
1480 github: { tokens: { "GitHub.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } },1451 yield* serverSettings.updateSettings({ sourceControlHosts: forClient });
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 } });
1496 yield* serverSettings.updateSettings({1452 yield* serverSettings.updateSettings({
1497 github: { hosts: { "github.com": { enabled: true, account: "work" } } },1453 sourceControlHosts: { bitbucket: { email: "other@example.com" } },
1498 });1454 });
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, {
1500 "github.com": "ghp_dotcom",1458 "github.com": "ghp_dotcom",
1501 "ghe.acme.test": "ghp_ghe",1459 "ghe.acme.test": "ghp_ghe",
1502 });1460 });
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.
1505 const cleared = yield* serverSettings.updateSettings({1463 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 },
1507 });1475 });
1508 assert.equal(cleared.github.tokens["github.com"] ?? "", "");1476 assert.equal(cleared.sourceControlHosts.bitbucket?.accessToken, "");
1509 assert.equal(cleared.github.tokens["ghe.acme.test"], "ghp_ghe");1477 const stored = (kind: string, field: string, serverHost: string | null) =>
1510 const remaining = yield* Effect.forEach(["github.com", "ghe.acme.test"], (host) =>1478 secrets.get(sourceControlHostSecretName({ kind, field, serverHost }));
1511 secrets.get(`github-token-${Buffer.from(host, "utf8").toString("base64url")}`),1479 assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null)));
1512 );1480 assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com")));
1513 assert.isTrue(Option.isNone(remaining[0]!));1481 assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test")));
1514 assert.isTrue(Option.isSome(remaining[1]!));
1515 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),1482 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1516 );1483 );
15171484
1518 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>1485 it.effect("keeps the retired keys for another load when a token cannot be copied", () => {
1519 Effect.gen(function* () {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* () {
1520 const serverConfig = yield* ServerConfig.ServerConfig;1508 const serverConfig = yield* ServerConfig.ServerConfig;
1521 const fileSystem = yield* FileSystem.FileSystem;1509 const fileSystem = yield* FileSystem.FileSystem;
1522 const secrets = yield* ServerSecretStore.ServerSecretStore;
1523 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1510 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1524 // A token was saved, then the user deleted it from settings.json directly.1511 const legacy = JSON.stringify({
1525 yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token"));1512 bitbucket: { email: "me@example.com", accessToken: "\u2022\u2022\u2022\u2022\u2022\u2022" },
1526 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");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")));1518 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy);
1531 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),1519 }).pipe(Effect.provide(layerSettings));
1532 );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", () =>
1535 Effect.gen(function* () {1523 Effect.gen(function* () {
1536 const serverConfig = yield* ServerConfig.ServerConfig;1524 const serverConfig = yield* ServerConfig.ServerConfig;
1537 const fileSystem = yield* FileSystem.FileSystem;1525 const fileSystem = yield* FileSystem.FileSystem;
1538 const secrets = yield* ServerSecretStore.ServerSecretStore;1526 const secrets = yield* ServerSecretStore.ServerSecretStore;
1539 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1527 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 );
1540 yield* fileSystem.writeFileString(1534 yield* fileSystem.writeFileString(
1541 serverConfig.settingsPath,1535 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 }),
1543 );1543 );
15441544
1545 // Loading alone moves it: no settings update is needed.1545 const loaded = (yield* serverSettings.getSettings).sourceControlHosts;
1546 const loaded = yield* serverSettings.getSettings;
15471546
1548 assert.equal(loaded.bitbucket.accessToken, "hand-edited-token");1547 assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" });
1549 assert.notInclude(1548 assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" });
1550 yield* fileSystem.readFileString(serverConfig.settingsPath),1549 assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } });
1551 "hand-edited-token",1550 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1552 );1551 assert.notInclude(raw, '"bitbucket":{"email"');
1553 const stored = yield* secrets.get("bitbucket-access-token");1552 assert.include(raw, "sourceControlHosts");
1554 assert.equal(1553 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1555 Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null,
1556 "hand-edited-token",
1557 );
1558 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),1554 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1559 );1555 );
15601556
1561 it.effect(1557 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",
1563 () =>1559 () =>
1564 Effect.gen(function* () {1560 Effect.gen(function* () {
1565 const serverConfig = yield* ServerConfig.ServerConfig;1561 const serverConfig = yield* ServerConfig.ServerConfig;
...@@ -1567,24 +1563,18 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1567,24 +1563,18 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1567 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1563 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1568 yield* fileSystem.writeFileString(1564 yield* fileSystem.writeFileString(
1569 serverConfig.settingsPath,1565 serverConfig.settingsPath,
1570 '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}',1566 '{"sourceControlHosts":{"bitbucket":{"accessToken":"hand-edited-token"}}}',
1571 );1567 );
15721568
1573 // The form resends the redacted token when only the email changes.1569 // Loading alone moves it: no settings update is needed.
1574 const forClient = ServerSettingsModule.redactServerSettingsForClient(1570 const loaded = yield* serverSettings.getSettings;
1575 yield* serverSettings.getSettings,
1576 ).bitbucket;
1577 const updated = yield* serverSettings.updateSettings({
1578 bitbucket: { email: "new@example.com", apiToken: forClient.apiToken },
1579 });
15801571
1581 assert.equal(updated.bitbucket.apiToken, "hand-edited-token");1572 assert.equal(loaded.sourceControlHosts.bitbucket?.accessToken, "hand-edited-token");
1582 assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token");
1583 assert.notInclude(1573 assert.notInclude(
1584 yield* fileSystem.readFileString(serverConfig.settingsPath),1574 yield* fileSystem.readFileString(serverConfig.settingsPath),
1585 "hand-edited-token",1575 "hand-edited-token",
1586 );1576 );
1587 }).pipe(Effect.provide(layerServerSettings())),1577 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1588 );1578 );
15891579
1590 it.effect("materializes provider secrets for terminal environment resolution", () =>1580 it.effect("materializes provider secrets for terminal environment resolution", () =>
apps/server/src/serverSettings.ts+202-127
...@@ -64,6 +64,13 @@ import {...@@ -64,6 +64,13 @@ import {
64 isModelSelectionProviderEnabled,64 isModelSelectionProviderEnabled,
65} from "@t3tools/shared/serverSettings";65} from "@t3tools/shared/serverSettings";
66import * as ServerSecretStore from "./auth/ServerSecretStore.ts";66import * 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
68export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";75export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
6976
...@@ -157,18 +164,47 @@ function usageLimitSourceSecretName(sourceId: string): string {...@@ -157,18 +164,47 @@ function usageLimitSourceSecretName(sourceId: string): string {
157 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;164 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
158}165}
159166
160const BITBUCKET_SECRET_NAMES = {167const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
161 accessToken: "bitbucket-access-token",
162 apiToken: "bitbucket-api-token",
163} as const;
164const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;
165168
166/** Hosts are case-insensitive; a patch can arrive before decoding lowercased its keys. */169/** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */
167function gitHubTokenSecretName(host: string): string {170function mapSlotValues(
168 return `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`;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 );
169}191}
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
173function redactProviderEnvironmentVariable(209function redactProviderEnvironmentVariable(
174 variable: ProviderInstanceEnvironmentVariable,210 variable: ProviderInstanceEnvironmentVariable,
...@@ -206,18 +242,10 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS...@@ -206,18 +242,10 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
206 },242 },
207 ]),243 ]),
208 );244 );
209 const bitbucket = {245 const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) =>
210 ...settings.bitbucket,246 redactSecret(value),
211 accessToken: redactSecret(settings.bitbucket.accessToken),247 );
212 apiToken: redactSecret(settings.bitbucket.apiToken),248 return { ...settings, providerInstances, usageLimitSources, sourceControlHosts };
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 };
221}249}
222250
223export function applyProviderInstanceMutation(251export function applyProviderInstanceMutation(
...@@ -394,10 +422,60 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson...@@ -394,10 +422,60 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson
394const LegacyProviderSettingsJson = fromLenientJson(422const LegacyProviderSettingsJson = fromLenientJson(
395 Schema.Struct({423 Schema.Struct({
396 providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),424 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)),
397 }),428 }),
398);429);
399const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson);430const 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
401// Drivers that start disabled, so a session in the history means the user479// Drivers that start disabled, so a session in the history means the user
402// turned them on before the instance kept an explicit flag.480// turned them on before the instance kept an explicit flag.
403const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(481const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(
...@@ -724,54 +802,41 @@ const make = Effect.gen(function* () {...@@ -724,54 +802,41 @@ const make = Effect.gen(function* () {
724 );802 );
725803
726 /**804 /**
727 * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load,805 * Moves source control host secrets hand-edited into settings.json into the secret store as
728 * so plaintext does not stay on disk. If the store is unavailable, the token keeps working806 * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps
729 * from the file and the move is retried on the next load.807 * working from the file and the move is retried on the next load.
730 */808 */
731 const moveInlineBitbucketTokens = (settings: ServerSettings) =>809 const moveInlineSourceControlHostSecrets = (settings: ServerSettings) =>
732 Effect.gen(function* () {810 Effect.gen(function* () {
733 const bitbucket = { ...settings.bitbucket };811 const moved = new Set<string>();
734 let moved = false;812 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {
735 for (const field of BITBUCKET_SECRET_FIELDS) {813 if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue;
736 const value = bitbucket[field];
737 if (value.length === 0 || value === SECRET_REDACTED) continue;
738 const stored = yield* secretStore814 const stored = yield* secretStore
739 .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value))815 .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value))
740 .pipe(816 .pipe(
741 Effect.as(true),817 Effect.as(true),
742 Effect.catch(() =>818 Effect.catch(() =>
743 Effect.logWarning("failed to move a Bitbucket token into the secret store", {819 Effect.logWarning("failed to move a source control secret into the secret store", {
744 field,820 kind: slot.kind,
821 field: slot.field,
745 }).pipe(Effect.as(false)),822 }).pipe(Effect.as(false)),
746 ),823 ),
747 );824 );
748 if (!stored) continue;825 if (stored) moved.add(sourceControlHostSecretName(slot));
749 bitbucket[field] = SECRET_REDACTED;
750 moved = true;
751 }826 }
752 const tokens = { ...settings.github.tokens };827 if (moved.size === 0) return settings;
753 for (const [host, value] of Object.entries(tokens)) {828 return {
754 if (value.length === 0 || value === SECRET_REDACTED) continue;829 ...settings,
755 const stored = yield* secretStore830 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>
756 .set(gitHubTokenSecretName(host), textEncoder.encode(value))831 moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value,
757 .pipe(832 ),
758 Effect.as(true),833 };
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;
770 });834 });
771835
772 const loadSettingsFromDisk = Effect.gen(function* () {836 const loadSettingsFromDisk = Effect.gen(function* () {
773 let settings = DEFAULT_SERVER_SETTINGS;837 let settings = DEFAULT_SERVER_SETTINGS;
774 let legacyProviders: Readonly<Record<string, unknown>> | undefined;838 let legacyProviders: Readonly<Record<string, unknown>> | undefined;
839 let legacySourceControl: Parameters<typeof migrateLegacySourceControlSettings>[1] = {};
775 // A file that failed to decode must stay on disk for the user to repair;840 // A file that failed to decode must stay on disk for the user to repair;
776 // the fold below only writes when it started from the file's real contents.841 // the fold below only writes when it started from the file's real contents.
777 let settingsFileTrusted = true;842 let settingsFileTrusted = true;
...@@ -782,6 +847,10 @@ const make = Effect.gen(function* () {...@@ -782,6 +847,10 @@ const make = Effect.gen(function* () {
782 const legacySettings = decodeLegacyProviderSettingsJsonExit(raw);847 const legacySettings = decodeLegacyProviderSettingsJsonExit(raw);
783 if (legacySettings._tag === "Success") {848 if (legacySettings._tag === "Success") {
784 legacyProviders = legacySettings.value.providers;849 legacyProviders = legacySettings.value.providers;
850 legacySourceControl = {
851 bitbucket: legacySettings.value.bitbucket,
852 github: legacySettings.value.github,
853 };
785 }854 }
786 if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {855 if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
787 const failure = decoded._tag === "Failure" ? decoded : legacySettings;856 const failure = decoded._tag === "Failure" ? decoded : legacySettings;
...@@ -855,12 +924,48 @@ const make = Effect.gen(function* () {...@@ -855,12 +924,48 @@ const make = Effect.gen(function* () {
855 const folded = settingsFileTrusted924 const folded = settingsFileTrusted
856 ? foldLegacyProjectSettings(loaded, legacyProjectRows)925 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
857 : loaded;926 : 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: [] };
858 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.954 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
859 const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;955 const migrated = settingsFileTrusted
860 // A file still carrying the retired `providers` map is rewritten once so956 ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings)
861 // the migrated instances persist and the old key disappears.957 : folded;
862 if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) {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 ) {
863 yield* writeSettingsAtomically(migrated);965 yield* writeSettingsAtomically(migrated);
966 for (const move of sourceControlMigration.secretMoves) {
967 yield* secretStore.remove(move.from).pipe(Effect.ignore);
968 }
864 }969 }
865 return migrated;970 return migrated;
866 });971 });
...@@ -933,39 +1038,30 @@ const make = Effect.gen(function* () {...@@ -933,39 +1038,30 @@ const make = Effect.gen(function* () {
933 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",1038 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
934 };1039 };
935 }1040 }
936 const bitbucket = { ...settings.bitbucket };1041 const storedSecrets = new Map<string, string>();
937 for (const field of BITBUCKET_SECRET_FIELDS) {1042 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {
938 if (bitbucket[field] !== SECRET_REDACTED) continue;1043 if (slot.value !== 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 }
954 const secret = yield* secretStore1044 const secret = yield* secretStore
955 .get(gitHubTokenSecretName(host))1045 .get(sourceControlHostSecretName(slot))
956 .pipe(1046 .pipe(
957 Effect.mapError(1047 Effect.mapError(
958 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),1048 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
959 ),1049 ),
960 );1050 );
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 );
962 }1055 }
963 return {1056 return {
964 ...settings,1057 ...settings,
965 providerInstances: providerInstances as ServerSettings["providerInstances"],1058 providerInstances: providerInstances as ServerSettings["providerInstances"],
966 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],1059 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
967 bitbucket,1060 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>
968 github: { ...settings.github, tokens },1061 value === SECRET_REDACTED
1062 ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "")
1063 : value,
1064 ),
969 };1065 };
970 });1066 });
9711067
...@@ -1107,56 +1203,36 @@ const make = Effect.gen(function* () {...@@ -1107,56 +1203,36 @@ const make = Effect.gen(function* () {
1107 });1203 });
1108 }1204 }
11091205
1110 const bitbucket = { ...next.bitbucket };1206 // Hand-edited plaintext not yet in the store, so the redaction marker can move it there.
1111 for (const field of BITBUCKET_SECRET_FIELDS) {1207 const inlineSecrets = new Map(
1112 let value = bitbucket[field];1208 sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) =>
1113 if (value === SECRET_REDACTED) {1209 slot.value.length > 0 && slot.value !== SECRET_REDACTED
1114 // The marker keeps what is saved. A plaintext value hand-edited into settings.json1210 ? [[sourceControlHostSecretName(slot), slot.value] as const]
1115 // is not in the secret store yet, so move it there instead of dropping it.1211 : [],
1116 const inline = current.bitbucket[field];1212 ),
1117 if (inline === SECRET_REDACTED || inline.length === 0) continue;1213 );
1118 value = inline;1214 const nextSecretNames = new Set<string>();
1119 }1215 const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => {
1120 const secretName = BITBUCKET_SECRET_NAMES[field];1216 const secretName = sourceControlHostSecretName(slot);
1121 if (value.length === 0) {1217 // The marker keeps what is saved; a hand-edited plaintext value moves into the store.
1122 changes.push({ kind: "remove", secretName, operation: "remove-secret" });1218 const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw;
1123 continue;1219 if (value === undefined) {
1124 }1220 nextSecretNames.add(secretName);
1125 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });1221 return SECRET_REDACTED;
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;
1141 }1222 }
1142 const secretName = gitHubTokenSecretName(host);
1143 if (value.length === 0) {1223 if (value.length === 0) {
1144 changes.push({ kind: "remove", secretName, operation: "remove-secret" });1224 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
1145 continue;1225 return "";
1146 }1226 }
1227 nextSecretNames.add(secretName);
1147 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });1228 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
1148 tokens[host] = SECRET_REDACTED;1229 return SECRET_REDACTED;
1149 }1230 });
1150 const nextHosts = new Set(1231 for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) {
1151 Object.keys(next.github.tokens).map((host) => host.trim().toLowerCase()),1232 const secretName = sourceControlHostSecretName(slot);
1152 );1233 if (nextSecretNames.has(secretName)) continue;
1153 for (const host of Object.keys(current.github.tokens)) {1234 if (changes.some((change) => change.secretName === secretName)) continue;
1154 if (nextHosts.has(host.trim().toLowerCase())) continue;1235 changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" });
1155 changes.push({
1156 kind: "remove",
1157 secretName: gitHubTokenSecretName(host),
1158 operation: "remove-stale-secret",
1159 });
1160 }1236 }
11611237
1162 return {1238 return {
...@@ -1164,8 +1240,7 @@ const make = Effect.gen(function* () {...@@ -1164,8 +1240,7 @@ const make = Effect.gen(function* () {
1164 ...next,1240 ...next,
1165 providerInstances: providerInstances as ServerSettings["providerInstances"],1241 providerInstances: providerInstances as ServerSettings["providerInstances"],
1166 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],1242 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
1167 bitbucket,1243 sourceControlHosts,
1168 github: { ...next.github, tokens },
1169 },1244 },
1170 changes,1245 changes,
1171 };1246 };
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...@@ -632,16 +632,22 @@ it.effect("prefers credentials saved in settings over the environment, without a
632 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));632 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
633633
634 yield* settings.updateSettings({634 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 },
636 });638 });
637 yield* bitbucket.probeAuth;639 yield* bitbucket.probeAuth;
638 assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token"));640 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 });
641 yield* bitbucket.probeAuth;645 yield* bitbucket.probeAuth;
642 assert.strictEqual(lastAuthorization(), "Bearer saved-access-token");646 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 });
645 yield* bitbucket.probeAuth;651 yield* bitbucket.probeAuth;
646 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));652 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
647 }).pipe(Effect.provide(layer));653 }).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...@@ -658,7 +664,9 @@ it.effect("never puts a saved token that is unsafe for an HTTP header on the wir
658664
659 // Fetch would reject this header with an error quoting the token, and that error reaches665 // Fetch would reject this header with an error quoting the token, and that error reaches
660 // clients. The unusable token is ignored, so the environment credential is used instead.666 // 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 });
662 yield* bitbucket.probeAuth;670 yield* bitbucket.probeAuth;
663 assert.strictEqual(671 assert.strictEqual(
664 execute.mock.calls.at(-1)?.[0].headers.authorization,672 execute.mock.calls.at(-1)?.[0].headers.authorization,
...@@ -679,7 +687,9 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm...@@ -679,7 +687,9 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm
679687
680 assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated");688 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 });
683 assert.deepStrictEqual(yield* bitbucket.probeAuth, {693 assert.deepStrictEqual(yield* bitbucket.probeAuth, {
684 status: "unknown",694 status: "unknown",
685 account: Option.none(),695 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";...@@ -27,6 +27,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
27import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";27import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
28import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";28import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
29import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";29import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
30import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
30import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";31import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
31import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";32import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
32import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";33import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
...@@ -56,6 +57,9 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -56,6 +57,9 @@ const layerSourceControlProviderRegistryTest = (input: {
56 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),57 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
57 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),58 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
58 Layer.mock(GitCafeApi.GitCafeApi)({}),59 Layer.mock(GitCafeApi.GitCafeApi)({}),
60 Layer.mock(GitCafeCredentials.GitCafeCredentials)({
61 cliEnv: () => Effect.succeed({}),
62 }),
59 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),63 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
60 Layer.mock(VcsProcess.VcsProcess)(input.process),64 Layer.mock(VcsProcess.VcsProcess)(input.process),
61 ServerSourceControlHost.layer.pipe(65 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";...@@ -26,6 +26,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
26import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";26import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
27import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";27import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
28import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";28import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi";
29import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials";
29import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";30import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
30import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";31import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
3132
...@@ -129,6 +130,9 @@ function makeRegistry(input: {...@@ -129,6 +130,9 @@ function makeRegistry(input: {
129 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),130 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}),
130 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),131 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
131 Layer.mock(GitCafeApi.GitCafeApi)({}),132 Layer.mock(GitCafeApi.GitCafeApi)({}),
133 Layer.mock(GitCafeCredentials.GitCafeCredentials)({
134 cliEnv: () => Effect.succeed({}),
135 }),
132 ServerConfig.layerTest(process.cwd(), {136 ServerConfig.layerTest(process.cwd(), {
133 prefix: "t3-source-control-registry-test-",137 prefix: "t3-source-control-registry-test-",
134 }).pipe(Layer.provide(NodeServices.layer)),138 }).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 @@...@@ -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
3export type GitHubDiscoveredAccount = NonNullable<SourceControlProviderAuth["accounts"]>[number];4export 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";...@@ -9,6 +9,7 @@ import { Button } from "../ui/button";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";10import { Switch } from "../ui/switch";
11import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic";11import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic";
12import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients";
12import { redactedPlaceholder } from "./RedactedSensitiveText";13import { redactedPlaceholder } from "./RedactedSensitiveText";
1314
14/** Sentinel select value for "follow gh's active login"; logins never contain spaces. */15/** Sentinel select value for "follow gh's active login"; logins never contain spaces. */
...@@ -49,7 +50,8 @@ export function GitHubAccountSettings({...@@ -49,7 +50,8 @@ export function GitHubAccountSettings({
49 readonly auth: SourceControlProviderAuth;50 readonly auth: SourceControlProviderAuth;
50 readonly onSaved: () => void;51 readonly onSaved: () => void;
51}) {52}) {
52 const hosts = useEnvironmentSettings(environmentId, (settings) => settings.github.hosts);53 const github = useEnvironmentSettings(environmentId, readGitHubSettings);
54 const hosts = github.hosts;
53 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {55 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
54 label: "save GitHub account settings",56 label: "save GitHub account settings",
55 });57 });
...@@ -65,7 +67,11 @@ export function GitHubAccountSettings({...@@ -65,7 +67,11 @@ export function GitHubAccountSettings({
65 try {67 try {
66 const result = await updateSettings({68 const result = await updateSettings({
67 environmentId,69 environmentId,
68 input: { patch: { github: { hosts: nextGitHubHosts(hosts, host, change) } } },70 input: {
71 patch: {
72 sourceControlHosts: { github: { hosts: nextGitHubHosts(hosts, host, change) } },
73 },
74 },
69 });75 });
70 if (result._tag === "Success") onSaved();76 if (result._tag === "Success") onSaved();
71 } finally {77 } finally {
apps/web/src/components/settings/GitHubTokenSettings.tsx+6-2
...@@ -3,6 +3,7 @@ import { ExternalLinkIcon } from "lucide-react";...@@ -3,6 +3,7 @@ import { ExternalLinkIcon } from "lucide-react";
3import { useState } from "react";3import { useState } from "react";
44
5import { useEnvironmentSettings } from "../../hooks/useSettings";5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients";
6import { serverEnvironment } from "../../state/server";7import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";8import { useAtomCommand } from "../../state/use-atom-command";
8import { Button, InlineButton } from "../ui/button";9import { Button, InlineButton } from "../ui/button";
...@@ -28,7 +29,7 @@ export function GitHubTokenSettings({...@@ -28,7 +29,7 @@ export function GitHubTokenSettings({
28 readonly environmentId: EnvironmentId;29 readonly environmentId: EnvironmentId;
29 readonly onSaved: () => void;30 readonly onSaved: () => void;
30}) {31}) {
31 const tokens = useEnvironmentSettings(environmentId, (settings) => settings.github.tokens);32 const { tokens } = useEnvironmentSettings(environmentId, readGitHubSettings);
32 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {33 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
33 label: "save GitHub token",34 label: "save GitHub token",
34 });35 });
...@@ -46,7 +47,10 @@ export function GitHubTokenSettings({...@@ -46,7 +47,10 @@ export function GitHubTokenSettings({
46 try {47 try {
47 const result = await updateSettings({48 const result = await updateSettings({
48 environmentId,49 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 },
50 });54 });
51 if (result._tag === "Success") {55 if (result._tag === "Success") {
52 setDraft("");56 setDraft("");
apps/web/src/components/settings/ProviderSettingsForm.tsx+30-6
...@@ -19,6 +19,11 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../...@@ -19,6 +19,11 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
19import { Switch } from "../ui/switch";19import { Switch } from "../ui/switch";
20import { Textarea } from "../ui/textarea";20import { Textarea } from "../ui/textarea";
21import type { ProviderClientDefinition } from "@t3tools/provider-core/client";21import 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};
22import { SettingsRow } from "./settingsLayout";27import { SettingsRow } from "./settingsLayout";
2328
24export interface ProviderSettingsFieldModel {29export interface ProviderSettingsFieldModel {
...@@ -28,6 +33,8 @@ export interface ProviderSettingsFieldModel {...@@ -28,6 +33,8 @@ export interface ProviderSettingsFieldModel {
28 readonly description?: string | undefined;33 readonly description?: string | undefined;
29 readonly placeholder?: string | undefined;34 readonly placeholder?: string | undefined;
30 readonly clearWhenEmpty: "omit" | "persist";35 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;
31 readonly defaultBooleanValue?: boolean | undefined;38 readonly defaultBooleanValue?: boolean | undefined;
32 /** Choices for a `select` control. The first entry is the default. */39 /** Choices for a `select` control. The first entry is the default. */
33 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;40 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
...@@ -63,7 +70,7 @@ function readProviderSettingsFormAnnotation(...@@ -63,7 +70,7 @@ function readProviderSettingsFormAnnotation(
63}70}
6471
65function readProviderSettingsFormSchemaAnnotation(72function readProviderSettingsFormSchemaAnnotation(
66 definition: ProviderClientDefinition,73 definition: SettingsFormDefinition,
67): ProviderSettingsFormSchemaAnnotation {74): ProviderSettingsFormSchemaAnnotation {
68 return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {};75 return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {};
69}76}
...@@ -77,7 +84,7 @@ function readFieldBooleanDefault(...@@ -77,7 +84,7 @@ function readFieldBooleanDefault(
77}84}
7885
79export function deriveProviderSettingsFields(86export function deriveProviderSettingsFields(
80 definition: ProviderClientDefinition,87 definition: SettingsFormDefinition,
81 value?: unknown,88 value?: unknown,
82): ReadonlyArray<ProviderSettingsFieldModel> {89): ReadonlyArray<ProviderSettingsFieldModel> {
83 const isLocalAcp =90 const isLocalAcp =
...@@ -124,6 +131,7 @@ export function deriveProviderSettingsFields(...@@ -124,6 +131,7 @@ export function deriveProviderSettingsFields(
124 ? { placeholder: formAnnotation.placeholder }131 ? { placeholder: formAnnotation.placeholder }
125 : {}),132 : {}),
126 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",133 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
134 ...(formAnnotation.secret ? { secret: true } : {}),
127 ...(formAnnotation.control === "switch"135 ...(formAnnotation.control === "switch"
128 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }136 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
129 : {}),137 : {}),
...@@ -247,6 +255,19 @@ function readProviderConfigString(config: unknown, key: string): string {...@@ -247,6 +255,19 @@ function readProviderConfigString(config: unknown, key: string): string {
247 return typeof value === "string" ? value : "";255 return typeof value === "string" ? value : "";
248}256}
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
250function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {271function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {
251 if (config === null || typeof config !== "object") return defaultValue;272 if (config === null || typeof config !== "object") return defaultValue;
252 const value = (config as Record<string, unknown>)[key];273 const value = (config as Record<string, unknown>)[key];
...@@ -281,7 +302,7 @@ export function nextProviderConfigWithFieldValue(...@@ -281,7 +302,7 @@ export function nextProviderConfigWithFieldValue(
281}302}
282303
283interface ProviderSettingsFormProps {304interface ProviderSettingsFormProps {
284 readonly definition: ProviderClientDefinition;305 readonly definition: SettingsFormDefinition;
285 readonly value: unknown;306 readonly value: unknown;
286 readonly idPrefix: string;307 readonly idPrefix: string;
287 /**308 /**
...@@ -410,9 +431,12 @@ function ProviderSettingsFieldRow({...@@ -410,9 +431,12 @@ function ProviderSettingsFieldRow({
410 className="w-full max-w-full @min-[32rem]/settings-row:w-56"431 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
411 type={field.control === "password" ? "password" : undefined}432 type={field.control === "password" ? "password" : undefined}
412 autoComplete={field.control === "password" ? "off" : undefined}433 autoComplete={field.control === "password" ? "off" : undefined}
413 value={readProviderConfigString(value, field.key)}434 {...secretFieldInputProps(field, value)}
414 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}435 onCommit={(next) => {
415 placeholder={field.placeholder}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 }}
416 spellCheck={false}440 spellCheck={false}
417 />441 />
418 );442 );
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";...@@ -46,7 +46,7 @@ import { Switch } from "../ui/switch";
46import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";46import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4747
48import { GitIcon, JujutsuIcon, type Icon } from "../Icons";48import { GitIcon, JujutsuIcon, type Icon } from "../Icons";
49import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";49import { SourceControlHostSettings } from "./SourceControlHostSettings";
50import { GitHubAccountSettings } from "./GitHubAccountSettings";50import { GitHubAccountSettings } from "./GitHubAccountSettings";
51import { GitHubTokenSettings } from "./GitHubTokenSettings";51import { GitHubTokenSettings } from "./GitHubTokenSettings";
52import { RedactedSensitiveText } from "./RedactedSensitiveText";52import { RedactedSensitiveText } from "./RedactedSensitiveText";
...@@ -59,7 +59,7 @@ import {...@@ -59,7 +59,7 @@ import {
59 SettingsSection,59 SettingsSection,
60 useSettingsSearchTargetId,60 useSettingsSearchTargetId,
61} from "./settingsLayout";61} from "./settingsLayout";
62import { searchableSetting } from "./settingsSearch";62import { searchableSetting, sourceControlHostSettingsSearchId } from "./settingsSearch";
63import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";63import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
64import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";64import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
65import { sourceControlIcon } from "~/sourceControlPresentation";65import { sourceControlIcon } from "~/sourceControlPresentation";
...@@ -278,8 +278,7 @@ function DiscoveryItemRow({...@@ -278,8 +278,7 @@ function DiscoveryItemRow({
278 useEffect(() => {278 useEffect(() => {
279 if (279 if (
280 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||280 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||
281 (item.kind === "bitbucket" &&281 searchTargetId === sourceControlHostSettingsSearchId(item.kind) ||
282 searchTargetId === searchableSetting("bitbucket-credentials").id) ||
283 (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id)282 (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id)
284 ) {283 ) {
285 setIsExpanded(true);284 setIsExpanded(true);
...@@ -527,6 +526,22 @@ export function SourceControlSettingsPanel() {...@@ -527,6 +526,22 @@ export function SourceControlSettingsPanel() {
527 const handleScan = () => {526 const handleScan = () => {
528 discovery.refresh();527 discovery.refresh();
529 };528 };
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 };
530 const scanButton = (545 const scanButton = (
531 <Tooltip>546 <Tooltip>
532 <TooltipTrigger547 <TooltipTrigger
...@@ -591,16 +606,7 @@ export function SourceControlSettingsPanel() {...@@ -591,16 +606,7 @@ export function SourceControlSettingsPanel() {
591 >606 >
592 {result.sourceControlProviders.map((item) => (607 {result.sourceControlProviders.map((item) => (
593 <DiscoveryItemRow key={`provider:${item.kind}`} item={item}>608 <DiscoveryItemRow key={`provider:${item.kind}`} item={item}>
594 {item.kind === "bitbucket" ? (609 {item.kind === "github" ? (
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" ? (
604 <SettingsSearchTarget id={searchableSetting("github-accounts").id}>610 <SettingsSearchTarget id={searchableSetting("github-accounts").id}>
605 <div className="grid gap-6">611 <div className="grid gap-6">
606 {/* Shown even without gh: a saved token is how GitHub works without the CLI. */}612 {/* Shown even without gh: a saved token is how GitHub works without the CLI. */}
...@@ -619,7 +625,9 @@ export function SourceControlSettingsPanel() {...@@ -619,7 +625,9 @@ export function SourceControlSettingsPanel() {
619 ) : null}625 ) : null}
620 </div>626 </div>
621 </SettingsSearchTarget>627 </SettingsSearchTarget>
622 ) : undefined}628 ) : (
629 hostSettingsPanel(item.kind)
630 )}
623 </DiscoveryItemRow>631 </DiscoveryItemRow>
624 ))}632 ))}
625 </SettingsSection>633 </SettingsSection>
apps/web/src/components/settings/settingsSearch.ts+25-8
...@@ -4,6 +4,7 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti...@@ -4,6 +4,7 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti
4import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts";4import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts";
5import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
6import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";6import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
7import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
7import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";8import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";
8import { commandLabel } from "./KeybindingsSettings.logic";9import { commandLabel } from "./KeybindingsSettings.logic";
9import {10import {
...@@ -132,6 +133,29 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right...@@ -132,6 +133,29 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right
132 };133 };
133});134});
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
135/**159/**
136 * Searchable settings and stable destinations, in result order. Rows with a160 * Searchable settings and stable destinations, in result order. Rows with a
137 * dedicated anchor render their id and title via `searchableSetting`; items161 * dedicated anchor render their id and title via `searchableSetting`; items
...@@ -808,14 +832,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -808,14 +832,7 @@ export const SETTINGS_SEARCH_ITEMS = [
808 environmentOnly: true,832 environmentOnly: true,
809 scope: "environment-defaults",833 scope: "environment-defaults",
810 },834 },
811 {835 ...SOURCE_CONTROL_HOST_SEARCH_ITEMS,
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 },
819 {836 {
820 id: "source-control-writing-style",837 id: "source-control-writing-style",
821 title: "Source control writing style",838 title: "Source control writing style",
docs/user/source-control.md+10-8
...@@ -64,18 +64,18 @@ glab auth login...@@ -64,18 +64,18 @@ glab auth login
6464
65### Bitbucket65### 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
69- **Access token**: a token created for one repository, project, or workspace. It can only reach69- **Access token**: a token created for one repository, project, or workspace. It can only reach
70 what it was created for.70 what it was created for.
71- **API token**: an Atlassian API token for your account, used with your account email. It can71- **Atlassian account email** and **API token**: an Atlassian API token for your account. It can
72 reach every repository you can. Give it read/write access to repositories and pull requests, plus72 reach every repository you can. Give it read/write access to repositories and pull requests, plus
73 user read access (`read:user:bitbucket`).73 user read access (`read:user:bitbucket`).
7474
75Choose **Save**; the change applies right away, and replaces any credential saved with the other75A field saves when you leave it, and applies right away. The access token wins when both are saved.
76method. Credentials are saved on the environment's server, so select a remote environment to76Credentials are saved on the environment's server, so select a remote environment to configure
77configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose77it. Saved tokens can't be viewed again; enter a new one to replace it, or choose **Remove** to clear
78**Remove**.78everything saved for Bitbucket.
7979
80If no credentials are saved, T3 Code falls back to these variables in the server's environment.80If no credentials are saved, T3 Code falls back to these variables in the server's environment.
81Restart the server after changing them:81Restart the server after changing them:
...@@ -105,8 +105,10 @@ bun install -g @gitcafe/cli...@@ -105,8 +105,10 @@ bun install -g @gitcafe/cli
105cafe auth login --host https://git.cafe/api105cafe auth login --host https://git.cafe/api
106```106```
107107
108Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only108Alternatively, open **Settings → Source Control**, expand **GitCafe**, and enter a token. It is
109sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on109kept 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
110`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`.112`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`.
111113
112Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet.114Line 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 @@...@@ -9,7 +9,11 @@
9 */9 */
10import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";10import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";
11import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition";11import * 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
14export {18export {
15 UNKNOWN_SOURCE_CONTROL_CLIENT,19 UNKNOWN_SOURCE_CONTROL_CLIENT,
...@@ -40,3 +44,13 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [...@@ -40,3 +44,13 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [
40export const sourceControlClients = makeSourceControlClientRegistry(44export const sourceControlClients = makeSourceControlClientRegistry(
41 BUILT_IN_SOURCE_CONTROL_CLIENTS,45 BUILT_IN_SOURCE_CONTROL_CLIENTS,
42);46);
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", () => {...@@ -949,13 +949,6 @@ describe("ServerSettingsPatch.providerInstances", () => {
949});949});
950950
951describe("ServerSettingsPatch string normalization", () => {951describe("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
959 it("trims string settings while decoding patches", () => {952 it("trims string settings while decoding patches", () => {
960 const patch = decodeServerSettingsPatch({953 const patch = decodeServerSettingsPatch({
961 addProjectBaseDirectory: " ~/Development ",954 addProjectBaseDirectory: " ~/Development ",
packages/contracts/src/settings.ts+22-56
...@@ -552,6 +552,11 @@ export interface ProviderSettingsFormAnnotation {...@@ -552,6 +552,11 @@ export interface ProviderSettingsFormAnnotation {
552 readonly placeholder?: string | undefined;552 readonly placeholder?: string | undefined;
553 readonly hidden?: boolean | undefined;553 readonly hidden?: boolean | undefined;
554 readonly clearWhenEmpty?: "omit" | "persist" | undefined;554 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;
555 /** Choices for a `select` control. The first entry is the default. */560 /** Choices for a `select` control. The first entry is the default. */
556 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;561 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
557}562}
...@@ -805,49 +810,20 @@ export const UsageLimitSourceConfig = Schema.Struct({...@@ -805,49 +810,20 @@ export const UsageLimitSourceConfig = Schema.Struct({
805});810});
806export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;811export 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 */
826/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */813/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */
827export const GitHubHost = TrimmedNonEmptyString.pipe(814export const GitHubHost = TrimmedNonEmptyString.pipe(
828 Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),815 Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),
829);816);
830817
831export const GitHubHostSettings = Schema.Struct({818/**
832 account: Schema.optionalKey(TrimmedNonEmptyString),819 * One source control host's saved settings. Each host package declares the fields it reads in
833 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),820 * its client definition's settings schema and decodes this blob with it; the environment stores
834});821 * it whole, so a host this build does not ship keeps its settings. A field annotated as a secret
835export type GitHubHostSettings = typeof GitHubHostSettings.Type;822 * holds a string, or a string per server host, whose value lives in the server's secret store:
836823 * settings and clients only see a redaction marker for a saved one.
837export const GitHubSettings = Schema.Struct({824 */
838 /** Keyed by lowercased host, for example `github.com`. */825export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown);
839 hosts: Schema.Record(GitHubHost, GitHubHostSettings).pipe(826export type SourceControlHostSettings = typeof SourceControlHostSettings.Type;
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;
851827
852export const ObservabilitySettings = Schema.Struct({828export const ObservabilitySettings = Schema.Struct({
853 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),829 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
...@@ -1278,8 +1254,10 @@ export const ServerSettings = Schema.Struct({...@@ -1278,8 +1254,10 @@ export const ServerSettings = Schema.Struct({
1278 Schema.withDecodingDefault(Effect.succeed({})),1254 Schema.withDecodingDefault(Effect.succeed({})),
1279 ),1255 ),
1280 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),1256 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1281 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),1257 /** Keyed by source control host kind, such as `gitcafe`; see `SourceControlHostSettings`. */
1282 github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),1258 sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).pipe(
1259 Schema.withDecodingDefault(Effect.succeed({})),
1260 ),
1283 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries1261 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
1284 // this build cannot decode round-trip untouched, as provider instances do.1262 // this build cannot decode round-trip untouched, as provider instances do.
1285 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(1263 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
...@@ -1523,24 +1501,12 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1523,24 +1501,12 @@ export const ServerSettingsPatch = Schema.Struct({
1523 otlpLogsUrl: Schema.optionalKey(TrimmedString),1501 otlpLogsUrl: Schema.optionalKey(TrimmedString),
1524 }),1502 }),
1525 ),1503 ),
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 ),
1534 /**1504 /**
1535 * `hosts` replaces the whole map, so an omitted host or account clears it. `tokens` merges per1505 * Merges per host: a patched field replaces the saved one, so a client sends a per-server-host
1536 * host: an empty token removes that host's token, the redaction marker keeps it.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.
1537 */1508 */
1538 github: Schema.optionalKey(1509 sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)),
1539 Schema.Struct({
1540 hosts: Schema.optionalKey(Schema.Record(GitHubHost, GitHubHostSettings)),
1541 tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)),
1542 }),
1543 ),
1544 // Whole-map replacement for the new instance config. Patching individual1510 // Whole-map replacement for the new instance config. Patching individual
1545 // entries is intentionally out of scope: the map is small, and partial1511 // entries is intentionally out of scope: the map is small, and partial
1546 // patches risk leaving driver-specific config in a half-merged state.1512 // 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", () => {...@@ -42,16 +42,19 @@ describe("serverSettings helpers", () => {
42 logsAfterDays: 30,42 logsAfterDays: 30,
43 });43 });
44 });44 });
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", () => {
46 const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {46 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: {},
48 });57 });
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 } });
55 });58 });
56 it("replaces SSH host lists when saving, editing, and removing hosts", () => {59 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
57 const host = { id: "mini", label: "Mac mini", target: "mini" };60 const host = { id: "mini", label: "Mac mini", target: "mini" };
packages/shared/src/serverSettings.ts+14-3
...@@ -267,6 +267,7 @@ export function applyServerSettingsPatch(...@@ -267,6 +267,7 @@ export function applyServerSettingsPatch(
267 worktreeCleanup: worktreeCleanupPatch,267 worktreeCleanup: worktreeCleanupPatch,
268 // Merged per entry below; its `null` removals must not reach deepMerge.268 // Merged per entry below; its `null` removals must not reach deepMerge.
269 usageLimitSources: usageLimitSourcesPatch,269 usageLimitSources: usageLimitSourcesPatch,
270 sourceControlHosts: sourceControlHostsPatch,
270 usagePriceOverrides: usagePriceOverridesPatch,271 usagePriceOverrides: usagePriceOverridesPatch,
271 usageModelAliases: usageModelAliasesPatch,272 usageModelAliases: usageModelAliasesPatch,
272 // Entry replacement: deepMerge would keep keys the client meant to clear.273 // Entry replacement: deepMerge would keep keys the client meant to clear.
...@@ -367,9 +368,19 @@ export function applyServerSettingsPatch(...@@ -367,9 +368,19 @@ export function applyServerSettingsPatch(
367 ],368 ],
368 }369 }
369 : {}),370 : {}),
370 // Host replacement: deepMerge would keep a cleared account pin.371 // Per host, a patched field replaces the saved one: deepMerge would keep a cleared account pin.
371 ...(patch.github?.hosts !== undefined372 ...(sourceControlHostsPatch !== undefined
372 ? { github: { ...next.github, hosts: patch.github.hosts } }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 }
373 : {}),384 : {}),
374 ...(projectSettingsOverridesPatch !== undefined385 ...(projectSettingsOverridesPatch !== undefined
375 ? {386 ? {
packages/source-control-bitbucket/src/client/definition.ts+43-1
...@@ -3,7 +3,13 @@...@@ -3,7 +3,13 @@
3 *3 *
4 * @module source-control-bitbucket/client/definition4 * @module source-control-bitbucket/client/definition
5 */5 */
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";
7import {13import {
8 defineSourceControlClient,14 defineSourceControlClient,
9 isChangeRequestInProjectRepository,15 isChangeRequestInProjectRepository,
...@@ -16,6 +22,41 @@ const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;...@@ -16,6 +22,41 @@ const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1622
17const KIND = SourceControlProviderKind.make("bitbucket");23const 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
19export const definition = defineSourceControlClient({60export const definition = defineSourceControlClient({
20 kind: KIND,61 kind: KIND,
21 label: "Bitbucket",62 label: "Bitbucket",
...@@ -54,4 +95,5 @@ export const definition = defineSourceControlClient({...@@ -54,4 +95,5 @@ export const definition = defineSourceControlClient({
54 canReadChangeRequestOnHost: (identity, link) =>95 canReadChangeRequestOnHost: (identity, link) =>
55 isChangeRequestOnProjectHost(KIND, identity, link),96 isChangeRequestOnProjectHost(KIND, identity, link),
56 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),97 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"),
98 settings,
57});99});
packages/source-control-bitbucket/src/server/BitbucketApi.ts+18-5
...@@ -7,10 +7,8 @@ import * as Layer from "effect/Layer";...@@ -7,10 +7,8 @@ import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import {9import {
10 DEFAULT_SERVER_SETTINGS,
11 NonNegativeInt,10 NonNegativeInt,
12 TrimmedNonEmptyString,11 TrimmedNonEmptyString,
13 type BitbucketSettings,
14 type SourceControlProviderAuth,12 type SourceControlProviderAuth,
15 type SourceControlRepositoryCloneUrls,13 type SourceControlRepositoryCloneUrls,
16 type SourceControlRepositoryVisibility,14 type SourceControlRepositoryVisibility,
...@@ -31,6 +29,9 @@ import {...@@ -31,6 +29,9 @@ import {
31import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";29import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
32import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";30import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
33import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";31import * 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";
34import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";35import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";
3536
36const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";37const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
...@@ -569,7 +570,7 @@ function credentialFrom(input: {...@@ -569,7 +570,7 @@ function credentialFrom(input: {
569 * stay as a fallback. Within each source the access token wins.570 * stay as a fallback. Within each source the access token wins.
570 */571 */
571function resolveCredential(572function resolveCredential(
572 settings: BitbucketSettings,573 settings: BitbucketClient.BitbucketSettings,
573 env: Config.Success<typeof BitbucketApiEnvConfig>,574 env: Config.Success<typeof BitbucketApiEnvConfig>,
574): BitbucketCredential | null {575): BitbucketCredential | null {
575 return (576 return (
...@@ -664,12 +665,24 @@ export const make = Effect.gen(function* () {...@@ -664,12 +665,24 @@ export const make = Effect.gen(function* () {
664665
665 // Read on every request so credentials saved in settings apply without a restart.666 // Read on every request so credentials saved in settings apply without a restart.
666 const currentCredential = host.settings.get.pipe(667 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 ),
668 Effect.catch((error) =>677 Effect.catch((error) =>
669 // No cause: a settings decode error can quote a hand-edited token.678 // No cause: a settings decode error can quote a hand-edited token.
670 Effect.logWarning("failed to read Bitbucket credentials from settings", {679 Effect.logWarning("failed to read Bitbucket credentials from settings", {
671 operation: error.operation,680 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 ),
673 ),686 ),
674 );687 );
675688
packages/source-control-core/src/client/definition.ts+43
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 SourceControlProviderKind,19 SourceControlProviderKind,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";21import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
22import * as Schema from "effect/Schema";
2223
23/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */24/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */
24export interface ChangeRequestTerminology {25export interface ChangeRequestTerminology {
...@@ -140,8 +141,20 @@ export interface SourceControlClientDefinition {...@@ -140,8 +141,20 @@ export interface SourceControlClientDefinition {
140 readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null;141 readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null;
141 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */142 /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */
142 readonly isChangeRequestUrl: (url: string) => boolean;143 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;
143}151}
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
145/**158/**
146 * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number.159 * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number.
147 * Matching the parsed path keeps a query or fragment that mentions another host's route out.160 * Matching the parsed path keeps a query or fragment that mentions another host's route out.
...@@ -293,3 +306,33 @@ export function makeSourceControlClientRegistry(...@@ -293,3 +306,33 @@ export function makeSourceControlClientRegistry(
293 definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host",306 definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host",
294 };307 };
295}308}
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 @@...@@ -3,7 +3,13 @@
3 *3 *
4 * @module source-control-gitcafe/client/definition4 * @module source-control-gitcafe/client/definition
5 */5 */
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";
7import {13import {
8 defineSourceControlClient,14 defineSourceControlClient,
9 isChangeRequestInProjectRepository,15 isChangeRequestInProjectRepository,
...@@ -16,6 +22,20 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);...@@ -16,6 +22,20 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]);
1622
17const KIND = SourceControlProviderKind.make("gitcafe");23const 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
19export const definition = defineSourceControlClient({39export const definition = defineSourceControlClient({
20 kind: KIND,40 kind: KIND,
21 label: "GitCafe",41 label: "GitCafe",
...@@ -64,4 +84,5 @@ export const definition = defineSourceControlClient({...@@ -64,4 +84,5 @@ export const definition = defineSourceControlClient({
64 return false;84 return false;
65 }85 }
66 },86 },
87 settings,
67});88});
packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts+38
...@@ -78,6 +78,44 @@ describe("GitCafeCredentials", () => {...@@ -78,6 +78,44 @@ describe("GitCafeCredentials", () => {
78 }).pipe(Effect.provide(layer));78 }).pipe(Effect.provide(layer));
79 });79 });
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
81 it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => {119 it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => {
82 const { layer, calls } = harness(() =>120 const { layer, calls } = harness(() =>
83 Effect.succeed(121 Effect.succeed(
packages/source-control-gitcafe/src/server/GitCafeCredentials.ts+50-10
...@@ -10,6 +10,9 @@ import * as Redacted from "effect/Redacted";...@@ -10,6 +10,9 @@ import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import * as HostProcess from "@t3tools/shared/HostProcess";11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";12import * 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
14/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */17/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */
15const TOKEN_TTL = Duration.minutes(5);18const TOKEN_TTL = Duration.minutes(5);
...@@ -19,7 +22,7 @@ const MISSING_TTL = Duration.seconds(10);...@@ -19,7 +22,7 @@ const MISSING_TTL = Duration.seconds(10);
19export interface GitCafeCredential {22export interface GitCafeCredential {
20 readonly host: string;23 readonly host: string;
21 readonly token: Redacted.Redacted<string>;24 readonly token: Redacted.Redacted<string>;
22 readonly source: "env" | "cafe";25 readonly source: "settings" | "env" | "cafe";
23}26}
2427
25/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */28/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */
...@@ -66,10 +69,17 @@ export class GitCafeCredentials extends Context.Service<...@@ -66,10 +69,17 @@ export class GitCafeCredentials extends Context.Service<
66 ) => Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError>;69 ) => Effect.Effect<GitCafeCredential, GitCafeCredentialUnavailableError>;
67 /** Drops the held token after GitCafe refused it, so the next read asks its source again. */70 /** Drops the held token after GitCafe refused it, so the next read asks its source again. */
68 readonly invalidate: (host: string) => Effect.Effect<void>;71 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>>>;
69 }77 }
70>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {}78>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {}
7179
72const normalizeHost = (host: string) => host.trim().toLowerCase();80const 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
74/**84/**
75 * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by85 * `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(...@@ -82,7 +92,7 @@ export function environmentToken(
82 const configured = env.CAFE_HOST?.trim();92 const configured = env.CAFE_HOST?.trim();
83 const configuredHost = configured93 const configuredHost = configured
84 ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host94 ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host
85 : "git.cafe";95 : DEFAULT_HOST;
86 if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null;96 if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null;
87 return env.CAFE_TOKEN?.trim() || null;97 return env.CAFE_TOKEN?.trim() || null;
88}98}
...@@ -97,7 +107,8 @@ function credentialPassword(output: string): string | null {...@@ -97,7 +107,8 @@ function credentialPassword(output: string): string | null {
97107
98/** @public Service construction is part of the canonical Effect module API. */108/** @public Service construction is part of the canonical Effect module API. */
99export const make = Effect.gen(function* () {109export const make = Effect.gen(function* () {
100 const { process } = yield* SourceControlHost.SourceControlHost;110 const sourceControlHost = yield* SourceControlHost.SourceControlHost;
111 const { process } = sourceControlHost;
101 const environment = yield* HostProcess.Environment;112 const environment = yield* HostProcess.Environment;
102 const workingDirectory = yield* HostProcess.WorkingDirectory;113 const workingDirectory = yield* HostProcess.WorkingDirectory;
103114
...@@ -134,14 +145,27 @@ export const make = Effect.gen(function* () {...@@ -134,14 +145,27 @@ export const make = Effect.gen(function* () {
134 ),145 ),
135 );146 );
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> {
138 const fromEnv = environmentToken(host, environment);166 const fromEnv = environmentToken(host, environment);
139 const token = fromEnv ?? (yield* fromCafe(host));167 const token = fromEnv ?? (yield* fromCafe(host));
140 return {168 return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "cafe" };
141 host,
142 token: Redacted.make(token),
143 source: fromEnv !== null ? "env" : "cafe",
144 } satisfies GitCafeCredential;
145 });169 });
146170
147 const cache = yield* Cache.makeWith(lookup, {171 const cache = yield* Cache.makeWith(lookup, {
...@@ -158,8 +182,24 @@ export const make = Effect.gen(function* () {...@@ -158,8 +182,24 @@ export const make = Effect.gen(function* () {
158 });182 });
159183
160 return GitCafeCredentials.of({184 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 ),
162 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),200 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),
201 cliEnv: (host) =>
202 fromSettings(host).pipe(Effect.map((saved) => (saved === null ? {} : { CAFE_TOKEN: saved }))),
163 });203 });
164});204});
165205
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts+5-13
...@@ -42,16 +42,8 @@ const failure = (code: string, status: number | null) => ({...@@ -42,16 +42,8 @@ const failure = (code: string, status: number | null) => ({
42});42});
4343
44describe("GitCafeSourceControlProvider", () => {44describe("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
53 it("reads a signed-in account and keeps transient failures apart from sign-out", () => {45 it("reads a signed-in account and keeps transient failures apart from sign-out", () => {
54 const { parseAuth } = GitCafeSourceControlProvider.discovery;46 const { parseAuth } = GitCafeSourceControlProvider;
55 const signedIn = parseAuth(47 const signedIn = parseAuth(
56 authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }),48 authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }),
57 );49 );
...@@ -115,7 +107,7 @@ describe("GitCafeSourceControlProvider", () => {...@@ -115,7 +107,7 @@ describe("GitCafeSourceControlProvider", () => {
115 }).pipe(107 }).pipe(
116 Effect.provide(108 Effect.provide(
117 GitCafeApi.layer.pipe(109 GitCafeApi.layer.pipe(
118 Layer.provide(GitCafeCredentials.layer),110 Layer.provideMerge(GitCafeCredentials.layer),
119 Layer.provideMerge(TestSourceControlHost.layer()),111 Layer.provideMerge(TestSourceControlHost.layer()),
120 Layer.provide(112 Layer.provide(
121 Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }),113 Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }),
...@@ -162,7 +154,7 @@ describe("GitCafeSourceControlProvider", () => {...@@ -162,7 +154,7 @@ describe("GitCafeSourceControlProvider", () => {
162 }).pipe(154 }).pipe(
163 Effect.provide(155 Effect.provide(
164 GitCafeApi.layer.pipe(156 GitCafeApi.layer.pipe(
165 Layer.provide(GitCafeCredentials.layer),157 Layer.provideMerge(GitCafeCredentials.layer),
166 Layer.provideMerge(158 Layer.provideMerge(
167 TestSourceControlHost.layer({159 TestSourceControlHost.layer({
168 git: {160 git: {
...@@ -244,7 +236,7 @@ describe("GitCafeSourceControlProvider", () => {...@@ -244,7 +236,7 @@ describe("GitCafeSourceControlProvider", () => {
244 }).pipe(236 }).pipe(
245 Effect.provide(237 Effect.provide(
246 GitCafeApi.layer.pipe(238 GitCafeApi.layer.pipe(
247 Layer.provide(GitCafeCredentials.layer),239 Layer.provideMerge(GitCafeCredentials.layer),
248 Layer.provideMerge(240 Layer.provideMerge(
249 TestSourceControlHost.layer({241 TestSourceControlHost.layer({
250 git: {242 git: {
...@@ -304,7 +296,7 @@ describe("GitCafeSourceControlProvider", () => {...@@ -304,7 +296,7 @@ describe("GitCafeSourceControlProvider", () => {
304 }).pipe(296 }).pipe(
305 Effect.provide(297 Effect.provide(
306 GitCafeApi.layer.pipe(298 GitCafeApi.layer.pipe(
307 Layer.provide(GitCafeCredentials.layer),299 Layer.provideMerge(GitCafeCredentials.layer),
308 Layer.provideMerge(300 Layer.provideMerge(
309 TestSourceControlHost.layer({301 TestSourceControlHost.layer({
310 process: {302 process: {
packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts+46-11
...@@ -11,13 +11,16 @@ import {...@@ -11,13 +11,16 @@ import {
11 type ChangeRequest,11 type ChangeRequest,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import {13import {
14 probeSourceControlProvider,
14 providerAuth,15 providerAuth,
15 type SourceControlCliDiscoverySpec,16 type SourceControlCliDiscoverySpec,
17 type SourceControlManagedCliDiscoverySpec,
16} from "@t3tools/source-control-core/server/discovery";18} from "@t3tools/source-control-core/server/discovery";
17import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";19import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
18import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";20import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
1921
20import * as GitCafeApi from "./GitCafeApi.ts";22import * as GitCafeApi from "./GitCafeApi.ts";
23import * as GitCafeCredentials from "./GitCafeCredentials.ts";
21import * as GitCafeHosts from "./gitCafeHosts.ts";24import * as GitCafeHosts from "./gitCafeHosts.ts";
2225
23/** Discovery reports the production account; repository operations pick their own host. */26/** Discovery reports the production account; repository operations pick their own host. */
...@@ -58,7 +61,7 @@ const cliFailure = (output: string) =>...@@ -58,7 +61,7 @@ const cliFailure = (output: string) =>
58 .map((line) => decodeFailure(line.trim()))61 .map((line) => decodeFailure(line.trim()))
59 .find(Result.isSuccess)?.success.error;62 .find(Result.isSuccess)?.success.error;
6063
61export const discovery = {64const cliDiscovery = {
62 type: "cli",65 type: "cli",
63 kind: SourceControlProviderKind.make("gitcafe"),66 kind: SourceControlProviderKind.make("gitcafe"),
64 label: "GitCafe",67 label: "GitCafe",
...@@ -90,9 +93,35 @@ export const discovery = {...@@ -90,9 +93,35 @@ export const discovery = {
90 detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`,93 detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`,
91 });94 });
92 },95 },
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.`,
94} satisfies SourceControlCliDiscoverySpec;97} 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
96const Repository = Schema.Struct({125const Repository = Schema.Struct({
97 name: TrimmedNonEmptyString,126 name: TrimmedNonEmptyString,
98 defaultBranch: Schema.NullOr(TrimmedNonEmptyString),127 defaultBranch: Schema.NullOr(TrimmedNonEmptyString),
...@@ -148,6 +177,7 @@ const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?...@@ -148,6 +177,7 @@ const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?
148export const make = Effect.gen(function* () {177export const make = Effect.gen(function* () {
149 const api = yield* GitCafeApi.GitCafeApi;178 const api = yield* GitCafeApi.GitCafeApi;
150 const host = yield* SourceControlHost.SourceControlHost;179 const host = yield* SourceControlHost.SourceControlHost;
180 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
151181
152 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>182 const error = (operation: string, cwd: string, detail: string, cause?: unknown) =>
153 new SourceControlProviderError({183 new SourceControlProviderError({
...@@ -174,15 +204,20 @@ export const make = Effect.gen(function* () {...@@ -174,15 +204,20 @@ export const make = Effect.gen(function* () {
174 ),204 ),
175 );205 );
176 const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray<string>) =>206 const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray<string>) =>
177 host.process207 credentials
178 .run({208 .cliEnv(target.host)
179 operation: `GitCafeSourceControlProvider.${operation}`,209 .pipe(
180 command: "cafe",210 Effect.flatMap((env) =>
181 cwd,211 host.process.run({
182 args: [...cliArgs(target.host), ...args],212 operation: `GitCafeSourceControlProvider.${operation}`,
183 env: { CAFE_OUTPUT: "json" },213 command: "cafe",
184 timeoutMs: 30_000,214 cwd,
185 })215 args: [...cliArgs(target.host), ...args],
216 env: { CAFE_OUTPUT: "json", ...env },
217 timeoutMs: 30_000,
218 }),
219 ),
220 )
186 .pipe(221 .pipe(
187 Effect.mapError((cause) =>222 Effect.mapError((cause) =>
188 error(223 error(
packages/source-control-gitcafe/src/server/driver.ts+1-1
...@@ -15,7 +15,7 @@ export const driver = defineSourceControlDriver({...@@ -15,7 +15,7 @@ export const driver = defineSourceControlDriver({
15 kind: SourceControlProviderKind.make("gitcafe"),15 kind: SourceControlProviderKind.make("gitcafe"),
16 make: Effect.all({16 make: Effect.all({
17 sourceControl: GitCafeSourceControlProvider.make,17 sourceControl: GitCafeSourceControlProvider.make,
18 discovery: Effect.succeed(GitCafeSourceControlProvider.discovery),18 discovery: GitCafeSourceControlProvider.makeDiscovery,
19 pullRequests: GitCafePullRequestProvider.make,19 pullRequests: GitCafePullRequestProvider.make,
20 }),20 }),
21});21});
packages/source-control-github/src/client/definition.ts+35-1
...@@ -3,7 +3,16 @@...@@ -3,7 +3,16 @@
3 *3 *
4 * @module source-control-github/client/definition4 * @module source-control-github/client/definition
5 */5 */
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";
7import {16import {
8 defineSourceControlClient,17 defineSourceControlClient,
9 isChangeRequestInProjectRepository,18 isChangeRequestInProjectRepository,
...@@ -17,6 +26,30 @@ const CHANGE_REQUEST_REFERENCE =...@@ -17,6 +26,30 @@ const CHANGE_REQUEST_REFERENCE =
1726
18const KIND = SourceControlProviderKind.make("github");27const 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
20export const definition = defineSourceControlClient({53export const definition = defineSourceControlClient({
21 kind: KIND,54 kind: KIND,
22 label: "GitHub",55 label: "GitHub",
...@@ -60,4 +93,5 @@ export const definition = defineSourceControlClient({...@@ -60,4 +93,5 @@ export const definition = defineSourceControlClient({
60 canReadChangeRequestOnHost: (identity, link) =>93 canReadChangeRequestOnHost: (identity, link) =>
61 isChangeRequestOnProjectHost(KIND, identity, link),94 isChangeRequestOnProjectHost(KIND, identity, link),
62 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"),95 isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"),
96 settings,
63});97});
packages/source-control-github/src/server/GitHubCredentials.test.ts+8-4
...@@ -57,7 +57,7 @@ function harness(...@@ -57,7 +57,7 @@ function harness(
57 Layer.provideMerge(57 Layer.provideMerge(
58 TestSourceControlHost.layer({58 TestSourceControlHost.layer({
59 settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {59 settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
60 github: { hosts: hostSettings(hosts), tokens },60 sourceControlHosts: { github: { hosts: hostSettings(hosts), tokens } },
61 }),61 }),
62 process: { run },62 process: { run },
63 }),63 }),
...@@ -128,16 +128,20 @@ describe("GitHubCredentials", () => {...@@ -128,16 +128,20 @@ describe("GitHubCredentials", () => {
128 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");128 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
129129
130 yield* settings.update({130 yield* settings.update({
131 github: { hosts: { "github.com": { account: "work", enabled: true } } },131 sourceControlHosts: {
132 github: { hosts: { "github.com": { account: "work", enabled: true } } },
133 },
132 });134 });
133 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work");135 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 });
136 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(140 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(
137 "GitHubHostDisabledError",141 "GitHubHostDisabledError",
138 );142 );
139143
140 yield* settings.update({ github: { hosts: {} } });144 yield* settings.update({ sourceControlHosts: { github: { hosts: {} } } });
141 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");145 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
142 // The unpinned token stayed cached; only the newly pinned account cost a gh call.146 // The unpinned token stayed cached; only the newly pinned account cost a gh call.
143 expect(calls).toHaveLength(2);147 expect(calls).toHaveLength(2);
packages/source-control-github/src/server/GitHubCredentials.ts+12-2
...@@ -14,6 +14,10 @@ import * as Schema from "effect/Schema";...@@ -14,6 +14,10 @@ import * as Schema from "effect/Schema";
14import * as HostProcess from "@t3tools/shared/HostProcess";14import * as HostProcess from "@t3tools/shared/HostProcess";
1515
16import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";16import * 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
18/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */22/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */
19const TOKEN_TTL = Duration.minutes(5);23const TOKEN_TTL = Duration.minutes(5);
...@@ -176,17 +180,23 @@ export const make = Effect.gen(function* () {...@@ -176,17 +180,23 @@ export const make = Effect.gen(function* () {
176 ),180 ),
177 );181 );
178182
183 const readSettings = (settings: ServerSettings) =>
184 readSourceControlHostSettings(
185 GitHubClient.settings,
186 settings.sourceControlHosts[GitHubClient.definition.kind],
187 );
188
179 /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */189 /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */
180 const hostChoice = (host: string) =>190 const hostChoice = (host: string) =>
181 sourceControlHost.settings.get.pipe(191 sourceControlHost.settings.get.pipe(
182 Effect.map((settings) => settings.github.hosts[host]),192 Effect.map((settings) => readSettings(settings).hosts[host]),
183 Effect.orElseSucceed(() => undefined),193 Effect.orElseSucceed(() => undefined),
184 );194 );
185195
186 /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */196 /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */
187 const savedToken = (host: string) =>197 const savedToken = (host: string) =>
188 sourceControlHost.settings.get.pipe(198 sourceControlHost.settings.get.pipe(
189 Effect.map((settings) => settings.github.tokens[host]?.trim() || null),199 Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null),
190 Effect.orElseSucceed(() => null),200 Effect.orElseSucceed(() => null),
191 );201 );
192202
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+10-6
...@@ -10,11 +10,9 @@ import * as RequestResolver from "effect/RequestResolver";...@@ -10,11 +10,9 @@ import * as RequestResolver from "effect/RequestResolver";
10import * as Result from "effect/Result";10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import {12import {
13 DEFAULT_SERVER_SETTINGS,
14 SourceControlProviderError,13 SourceControlProviderError,
15 TrimmedNonEmptyString,14 TrimmedNonEmptyString,
16 type ChangeRequest,15 type ChangeRequest,
17 type GitHubSettings,
18 type SourceControlProviderDiscoveryItem,16 type SourceControlProviderDiscoveryItem,
19 type SourceControlRepositoryCloneUrls,17 type SourceControlRepositoryCloneUrls,
20} from "@t3tools/contracts";18} from "@t3tools/contracts";
...@@ -55,6 +53,9 @@ import {...@@ -55,6 +53,9 @@ import {
55 type SourceControlManagedCliDiscoverySpec,53 type SourceControlManagedCliDiscoverySpec,
56} from "@t3tools/source-control-core/server/discovery";54} from "@t3tools/source-control-core/server/discovery";
57import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";55import * 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
59const decodeLinkSubject = Schema.decodeUnknownEffect(60const decodeLinkSubject = Schema.decodeUnknownEffect(
60 Schema.fromJsonString(61 Schema.fromJsonString(
...@@ -85,7 +86,7 @@ function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {...@@ -85,7 +86,7 @@ function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
85 */86 */
86export function parseGitHubAuth(87export function parseGitHubAuth(
87 input: SourceControlAuthProbeInput,88 input: SourceControlAuthProbeInput,
88 settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github,89 settings: GitHubClient.GitHubSettings = readSourceControlHostSettings(GitHubClient.settings, {}),
89) {90) {
90 const output = combinedAuthOutput(input);91 const output = combinedAuthOutput(input);
91 const authStatus = parseGitHubAuthStatus(input.stdout);92 const authStatus = parseGitHubAuthStatus(input.stdout);
...@@ -202,9 +203,12 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -202,9 +203,12 @@ export const makeDiscovery = Effect.gen(function* () {
202 label: discovery.label,203 label: discovery.label,
203 installHint: discovery.installHint,204 installHint: discovery.installHint,
204 probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) {205 probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) {
205 const settings = yield* sourceControlHost.settings.get.pipe(206 const settings = readSourceControlHostSettings(
206 Effect.map((current) => current.github),207 GitHubClient.settings,
207 Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github),208 yield* sourceControlHost.settings.get.pipe(
209 Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]),
210 Effect.orElseSucceed(() => undefined),
211 ),
208 );212 );
209 const cli = yield* probeSourceControlProvider({213 const cli = yield* probeSourceControlProvider({
210 cwd,214 cwd,
packages/source-control-github/src/server/gitHubAuthStatus.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { GitHubSettings } from "@t3tools/contracts";1import type { GitHubSettings } from "../client/definition.ts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
44