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