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