diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 2cb07051..5b024959 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -894,6 +894,55 @@ prisma-cli git disconnect --project proj_123 prisma-cli git disconnect --json ``` +## `prisma-cli git account list` + +Purpose: + +- show the GitHub accounts connected to the active workspace, and which accounts can be connected without reinstalling + +Behavior: + +- requires auth +- lists connected accounts with their login, account type (`user` or `organization`), numeric installation id, and suspension state +- lists connectable accounts: GitHub accounts already installed via other workspaces the user belongs to, deduplicated per account with the newest installation winning, so they can be connected here without a GitHub round trip +- suggests `git account connect` as a next step + +Examples: + +```bash +prisma-cli git account list +prisma-cli git account list --json +``` + +## `prisma-cli git account connect [account]` + +Purpose: + +- add a GitHub account to the active workspace, either by connecting one already installed elsewhere or by installing the Prisma GitHub App on a new account + +Behavior: + +- requires auth +- with `[account]` (login or numeric installation id): connects that account directly, no prompt. This is the path agents and CI use. +- without `[account]` in an interactive terminal: shows a picker of connectable accounts plus a `+ Install a new GitHub account` option. Choosing an account connects it; choosing install opens GitHub in the browser and waits for the install to finish, so the command never dead-ends without a result. +- without `[account]` in a non-interactive context (agent, CI, `--json`): never prompts. Fails with `GIT_ACCOUNT_REQUIRED`, carrying the connectable accounts in `error.meta.connectable` (and the install URL in `error.meta.installUrl` when nothing is connectable) plus runnable next steps, so the caller can pick without blocking. +- an unknown account fails with `GIT_ACCOUNT_NOT_FOUND` and the same machine-readable options +- authorization is enforced by the platform: a full user session whose user is a member of a workspace the account is already actively connected to +- if the browser install does not complete before the wait times out, fails with `GIT_INSTALL_TIMED_OUT`; the install may still finish, so re-check with `git account list` +- if GitHub reports the installation gone, fails with `GIT_CONNECT_FAILED` after the platform cleans up its stale records +- this connects the account (organization/user) to the workspace; link a specific repository to a project with `git connect` + +Examples: + +```bash +# Interactive: pick a connectable account or install a new one +prisma-cli git account connect + +# Non-interactive: connect a specific account +prisma-cli git account connect acme-org +prisma-cli git account connect 555003 --json +``` + ## `prisma-cli branch list` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index ef9fa9ff..dbbb7d47 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -181,6 +181,10 @@ These codes are the minimum stable set for the MVP: - `LOCAL_STATE_WRITE_FAILED` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` +- `GIT_ACCOUNT_REQUIRED` +- `GIT_ACCOUNT_NOT_FOUND` +- `GIT_CONNECT_FAILED` +- `GIT_INSTALL_TIMED_OUT` - `COMPUTE_CONFIG_INVALID` - `COMPUTE_CONFIG_TARGET_REQUIRED` - `COMPUTE_CONFIG_TARGET_UNKNOWN` @@ -254,6 +258,10 @@ Recommended meanings: - `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context +- `GIT_ACCOUNT_REQUIRED`: `git account connect` needs a GitHub account in a non-interactive context; connectable accounts are in `error.meta.connectable` (and `error.meta.installUrl` when nothing is connectable) and as next steps +- `GIT_ACCOUNT_NOT_FOUND`: the requested GitHub account is not connectable to the active workspace +- `GIT_CONNECT_FAILED`: GitHub reports the installation no longer exists; stale platform records were cleaned up +- `GIT_INSTALL_TIMED_OUT`: the browser GitHub App install did not complete before the wait timed out; it may still finish, so re-check with `git account list` - `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate - `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred - `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index 6749e8b6..fd397a93 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -1,7 +1,13 @@ { "providers": [ - { "id": "github", "name": "GitHub" }, - { "id": "google", "name": "Google" } + { + "id": "github", + "name": "GitHub" + }, + { + "id": "google", + "name": "Google" + } ], "users": [ { @@ -225,10 +231,39 @@ "end": "2026-06-30T23:59:59.999Z" }, "metrics": { - "operations": { "used": 12500, "unit": "ops" }, - "storage": { "used": 1.25, "unit": "GiB" } + "operations": { + "used": 12500, + "unit": "ops" + }, + "storage": { + "used": 1.25, + "unit": "GiB" + } }, "generatedAt": "2026-07-01T00:00:00.000Z" } + ], + "scmInstallations": [ + { + "installationId": 555001, + "accountId": 700100, + "accountLogin": "acme-bot", + "accountType": "organization", + "workspaceId": "ws_123" + }, + { + "installationId": 555002, + "accountId": 700200, + "accountLogin": "prisma-labs", + "accountType": "organization", + "workspaceId": "ws_456" + }, + { + "installationId": 555003, + "accountId": 700200, + "accountLogin": "prisma-labs", + "accountType": "organization", + "workspaceId": "ws_456" + } ] } diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 8b43bc7e..b8752949 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -25,6 +25,16 @@ interface MembershipRecord { workspaceId: string; } +interface ScmInstallationRecord { + /** Numeric GitHub App installation id. */ + installationId: number; + accountId: number; + accountLogin: string; + accountType: "user" | "organization"; + suspended?: boolean; + workspaceId: string; +} + interface ProjectRecord { id: string; name: string; @@ -93,6 +103,7 @@ interface MockApiData { users: UserRecord[]; workspaces: WorkspaceRecord[]; memberships: MembershipRecord[]; + scmInstallations?: ScmInstallationRecord[]; projects: ProjectRecord[]; branches: BranchRecord[]; deployments: DeploymentRecord[]; @@ -264,6 +275,69 @@ export class MockApi { return { outcome: "transferred", project }; } + listScmInstallations(workspaceId: string) { + return (this.data.scmInstallations ?? []) + .filter((record) => record.workspaceId === workspaceId) + .map((record) => ({ + installationId: record.installationId, + accountLogin: record.accountLogin, + accountType: record.accountType, + suspended: record.suspended ?? false, + })); + } + + // Fixture sessions belong to every fixture workspace, so connectable means + // active rows of other workspaces, deduped per account with the last + // fixture entry (the newest) winning — mirroring the platform rule. + listConnectableScmInstallations(workspaceId: string) { + const rows = this.data.scmInstallations ?? []; + const accountsConnectedHere = new Set( + rows + .filter((record) => record.workspaceId === workspaceId) + .map((record) => record.accountId), + ); + const seenAccounts = new Set(); + const connectable: { installationId: number; accountLogin: string }[] = []; + for (const record of [...rows].reverse()) { + if (record.workspaceId === workspaceId) continue; + if (accountsConnectedHere.has(record.accountId)) continue; + if (seenAccounts.has(record.accountId)) continue; + seenAccounts.add(record.accountId); + connectable.push({ + installationId: record.installationId, + accountLogin: record.accountLogin, + }); + } + return connectable; + } + + connectScmInstallation(workspaceId: string, installationId: number) { + const source = (this.data.scmInstallations ?? []).find( + (record) => record.installationId === installationId, + ); + if (!source) { + throw new Error(`Unknown fixture installation ${installationId}`); + } + const connected: ScmInstallationRecord = { + installationId: source.installationId, + accountId: source.accountId, + accountLogin: source.accountLogin, + accountType: source.accountType, + suspended: source.suspended ?? false, + workspaceId, + }; + this.data.scmInstallations = [ + ...(this.data.scmInstallations ?? []), + connected, + ]; + return { + installationId: connected.installationId, + accountLogin: connected.accountLogin, + accountType: connected.accountType, + suspended: connected.suspended ?? false, + }; + } + listBranchesForProject(projectId: string): BranchRecord[] { return this.data.branches.filter( (branch) => branch.projectId === projectId, diff --git a/packages/cli/src/commands/git/index.ts b/packages/cli/src/commands/git/index.ts index 8d396e33..6de1310c 100644 --- a/packages/cli/src/commands/git/index.ts +++ b/packages/cli/src/commands/git/index.ts @@ -1,9 +1,18 @@ import { Command } from "commander"; -import { runGitConnect, runGitDisconnect } from "../../controllers/project"; import { + runGitAccountConnect, + runGitAccountList, + runGitConnect, + runGitDisconnect, +} from "../../controllers/project"; +import { + renderGitAccountConnect, + renderGitAccountList, renderGitConnect, renderGitDisconnect, + serializeGitAccountConnect, + serializeGitAccountList, } from "../../presenters/project"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { runCommand } from "../../shell/command-runner"; @@ -12,7 +21,11 @@ import { addGlobalFlags, } from "../../shell/global-flags"; import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; -import type { ProjectRepositoryConnectionResult } from "../../types/project"; +import type { + GitAccountsResult, + GitConnectAccountResult, + ProjectRepositoryConnectionResult, +} from "../../types/project"; export function createGitCommand(runtime: CliRuntime): Command { const git = attachCommandDescriptor( @@ -24,6 +37,7 @@ export function createGitCommand(runtime: CliRuntime): Command { git.addCommand(createGitConnectCommand(runtime)); git.addCommand(createGitDisconnectCommand(runtime)); + git.addCommand(createGitAccountCommand(runtime)); return git; } @@ -86,3 +100,71 @@ function createGitDisconnectCommand(runtime: CliRuntime): Command { return command; } + +function createGitAccountCommand(runtime: CliRuntime): Command { + const account = attachCommandDescriptor( + configureRuntimeCommand(new Command("account"), runtime), + "git.account", + ); + + addCompactGlobalFlags(account); + + account.addCommand(createGitAccountListCommand(runtime)); + account.addCommand(createGitAccountConnectCommand(runtime)); + + return account; +} + +function createGitAccountListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("list"), runtime), + "git.account.list", + ); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "git.account.list", + options as Record, + (context) => runGitAccountList(context), + { + renderHuman: (context, descriptor, result) => + renderGitAccountList(context, descriptor, result), + renderJson: (result) => serializeGitAccountList(result), + }, + ); + }); + + return command; +} + +function createGitAccountConnectCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("connect"), runtime), + "git.account.connect", + ); + + command.argument( + "[account]", + "GitHub account login or numeric installation id", + ); + addGlobalFlags(command); + + command.action(async (account: string | undefined, options) => { + await runCommand( + runtime, + "git.account.connect", + options as Record, + (context) => runGitAccountConnect(context, account), + { + renderHuman: (context, descriptor, result) => + renderGitAccountConnect(context, descriptor, result), + renderJson: (result) => serializeGitAccountConnect(result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index a0033321..551588ae 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -69,10 +69,16 @@ import { workspaceRequiredError, } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; +import { selectPrompt } from "../shell/prompt"; import { type CommandContext, canPrompt } from "../shell/runtime"; import { renderSummaryLine } from "../shell/ui"; import type { AuthWorkspace } from "../types/auth"; import type { + GitAccountSummary, + GitAccountsResult, + GitConnectAccountResult, + GitConnectableAccountSummary, + GitInstallResult, GitRepositoryConnection, ProjectListResult, ProjectRemoveResult, @@ -1606,6 +1612,37 @@ interface SourceRepositoryApiClient { }; }> >; + GET( + path: "/v1/scm-installations/connectable", + options: { + params: { + query: { + workspaceId: string; + }; + }; + signal?: AbortSignal; + }, + ): Promise< + SourceRepositoryApiResult<{ + data: { + type: "connectable-scm-installation"; + provider: "github"; + installationId: number; + accountLogin: string; + }[]; + }> + >; + POST( + path: "/v1/scm-installations/connect", + options: { + body: { + provider: "github"; + workspaceId: string; + installationId: number; + }; + signal?: AbortSignal; + }, + ): Promise>; GET( path: "/v1/scm-installations/{installationId}/repositories", options: { @@ -2286,3 +2323,431 @@ function repoConnectionFixForStatus(status: number): string { return "Re-run with --trace for the underlying API response details."; } + +function toGitAccountSummary(record: { + installationId: number; + accountLogin: string; + accountType: "user" | "organization"; + suspended: boolean; +}): GitAccountSummary { + return { + installationId: record.installationId, + accountLogin: record.accountLogin, + accountType: record.accountType, + suspended: record.suspended, + }; +} + +async function resolveGitAccountContext(context: CommandContext): Promise<{ + workspace: NonNullable< + Awaited>["workspace"] + >; + formatCommand: PrismaCliPackageCommandFormatter; + api: SourceRepositoryApiClient | null; +}> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (!isRealMode(context)) { + return { workspace, formatCommand, api: null }; + } + + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(); + } + return { + workspace, + formatCommand, + api: client as unknown as SourceRepositoryApiClient, + }; +} + +async function listConnectableGitAccounts( + api: SourceRepositoryApiClient, + workspaceId: string, + signal: AbortSignal, +): Promise { + const { data, error, response } = await api.GET( + "/v1/scm-installations/connectable", + { params: { query: { workspaceId } }, signal }, + ); + if (error || !data) { + throw repoConnectionApiError( + "Failed to list connectable GitHub accounts", + response, + error, + ); + } + return data.data.map((record) => ({ + installationId: record.installationId, + accountLogin: record.accountLogin, + })); +} + +export async function runGitAccountList( + context: CommandContext, +): Promise> { + const { workspace, formatCommand, api } = + await resolveGitAccountContext(context); + + const [connected, connectable] = api + ? await Promise.all([ + listScmInstallations(api, workspace.id, context.runtime.signal).then( + (rows) => rows.map(toGitAccountSummary), + ), + listConnectableGitAccounts(api, workspace.id, context.runtime.signal), + ]) + : [ + context.api.listScmInstallations(workspace.id).map(toGitAccountSummary), + context.api.listConnectableScmInstallations(workspace.id), + ]; + + return { + command: "git.account.list", + result: { workspace, connected, connectable }, + warnings: [], + nextSteps: + connectable.length > 0 + ? [ + formatCommand([ + "git", + "account", + "connect", + connectable[0].accountLogin, + ]), + ] + : [formatCommand(["git", "account", "connect"])], + }; +} + +const INSTALL_NEW_ACCOUNT = Symbol("install-new-account"); + +export async function runGitAccountConnect( + context: CommandContext, + accountRef: string | undefined, +): Promise> { + const { workspace, formatCommand, api } = + await resolveGitAccountContext(context); + + const connectable = api + ? await listConnectableGitAccounts( + api, + workspace.id, + context.runtime.signal, + ) + : context.api.listConnectableScmInstallations(workspace.id); + + // Explicit account: resolve and connect, no prompt (agent-friendly path). + if (accountRef) { + const target = connectable.find( + (candidate) => + candidate.accountLogin === accountRef || + String(candidate.installationId) === accountRef, + ); + if (!target) { + throw gitAccountNotFoundError(accountRef, connectable, formatCommand); + } + return connectResult(context, workspace, api, target.installationId); + } + + // No account and no TTY (agent / CI / --json): never prompt. Return the + // connectable options as structured data, or the install URL when there is + // nothing to connect, so the caller can decide without blocking. + if (!canPrompt(context)) { + const installUrl = + connectable.length === 0 + ? await resolveInstallUrl(context, api, workspace.id) + : undefined; + throw gitAccountRequiredError(connectable, installUrl, formatCommand); + } + + // Interactive: pick a connectable account, or install a brand-new one. + const choice = await selectPrompt({ + input: context.runtime.stdin, + output: context.runtime.stderr, + signal: context.runtime.signal, + message: "Connect a GitHub account:", + choices: [ + ...connectable.map((candidate) => ({ + label: `${candidate.accountLogin} (installed via another workspace)`, + value: candidate, + })), + { label: "+ Install a new GitHub account", value: INSTALL_NEW_ACCOUNT }, + ], + }); + + if (choice === INSTALL_NEW_ACCOUNT) { + return installAndWaitForAccount(context, workspace, api, formatCommand); + } + + return connectResult( + context, + workspace, + api, + (choice as GitConnectableAccountSummary).installationId, + ); +} + +async function connectResult( + context: CommandContext, + workspace: GitConnectAccountResult["workspace"], + api: SourceRepositoryApiClient | null, + installationId: number, +): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); + + let account: GitAccountSummary; + if (api) { + const { data, error, response } = await api.POST( + "/v1/scm-installations/connect", + { + body: { + provider: "github", + workspaceId: workspace.id, + installationId, + }, + signal: context.runtime.signal, + }, + ); + if (error || !data) { + throw gitConnectAccountError(response, error, formatCommand); + } + account = toGitAccountSummary(data.data); + } else { + account = toGitAccountSummary( + context.api.connectScmInstallation(workspace.id, installationId), + ); + } + + return { + command: "git.account.connect", + result: { workspace, account, newlyInstalled: false }, + warnings: [], + nextSteps: [formatCommand(["git", "account", "list"])], + }; +} + +async function resolveInstallUrl( + context: CommandContext, + api: SourceRepositoryApiClient | null, + workspaceId: string, +): Promise { + if (!api) { + return "https://github.com/apps/prisma/installations/new?state=fixture-nonce"; + } + return createGitHubInstallIntent(api, workspaceId, context.runtime.signal); +} + +// Opens the GitHub App install page and waits for the new account to appear, +// so an install never dead-ends without a response. Mirrors the poll the repo +// connect flow already uses. +async function installAndWaitForAccount( + context: CommandContext, + workspace: GitConnectAccountResult["workspace"], + api: SourceRepositoryApiClient | null, + formatCommand: PrismaCliPackageCommandFormatter, +): Promise> { + if (!api) { + // Fixture mode has no real GitHub round trip; return the seeded account so + // the interactive path stays exercisable offline. + const account = toGitAccountSummary( + context.api.listScmInstallations(workspace.id)[0] ?? { + installationId: 0, + accountLogin: "fixture-account", + accountType: "organization" as const, + suspended: false, + }, + ); + return { + command: "git.account.connect", + result: { workspace, account, newlyInstalled: true }, + warnings: [], + nextSteps: [formatCommand(["git", "account", "list"])], + }; + } + + const before = new Set( + (await listScmInstallations(api, workspace.id, context.runtime.signal)).map( + (row) => row.installationId, + ), + ); + const installUrl = await createGitHubInstallIntent( + api, + workspace.id, + context.runtime.signal, + ); + const opened = await openInstallUrlIfInteractive(context, installUrl); + writeInstallWaitStatus(context, opened, installUrl); + + const account = await waitForNewInstallation( + context, + api, + workspace.id, + before, + ); + if (!account) { + throw gitInstallTimedOutError(installUrl, formatCommand); + } + + return { + command: "git.account.connect", + result: { workspace, account, newlyInstalled: true }, + warnings: [], + nextSteps: [formatCommand(["git", "account", "list"])], + }; +} + +async function waitForNewInstallation( + context: CommandContext, + api: SourceRepositoryApiClient, + workspaceId: string, + knownInstallationIds: Set, +): Promise { + const timeoutMs = readPositiveIntegerEnv( + context.runtime.env.PRISMA_CLI_GITHUB_INSTALL_TIMEOUT_MS, + GITHUB_INSTALL_POLL_TIMEOUT_MS, + ); + const intervalMs = readPositiveIntegerEnv( + context.runtime.env.PRISMA_CLI_GITHUB_INSTALL_POLL_INTERVAL_MS, + GITHUB_INSTALL_POLL_INTERVAL_MS, + ); + const deadline = Date.now() + timeoutMs; + + while (Date.now() <= deadline) { + context.runtime.signal.throwIfAborted(); + // biome-ignore lint/performance/noAwaitInLoops: Polling waits for each remote inspection before retrying. + const installations = await listScmInstallations( + api, + workspaceId, + context.runtime.signal, + ); + const fresh = installations.find( + (row) => !knownInstallationIds.has(row.installationId), + ); + if (fresh) { + return toGitAccountSummary(fresh); + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + await sleep(Math.min(intervalMs, remainingMs), context.runtime.signal); + } + + return null; +} + +function gitAccountRequiredError( + connectable: GitConnectableAccountSummary[], + installUrl: string | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + return new CliError({ + code: "GIT_ACCOUNT_REQUIRED", + domain: "project", + summary: "GitHub account required", + why: + connectable.length > 0 + ? "Pass the GitHub account to connect, or run in a terminal to pick one." + : "No GitHub account is connectable to this workspace; install the Prisma GitHub App to add one.", + fix: + connectable.length > 0 + ? "Rerun with one of the connectable accounts." + : "Open the install URL in a browser to install the app, then rerun.", + exitCode: 2, + meta: { connectable, ...(installUrl ? { installUrl } : {}) }, + nextSteps: + connectable.length > 0 + ? connectable.map((candidate) => + formatCommand([ + "git", + "account", + "connect", + candidate.accountLogin, + ]), + ) + : [formatCommand(["git", "account", "connect"])], + }); +} + +function gitAccountNotFoundError( + accountRef: string, + connectable: GitConnectableAccountSummary[], + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + return new CliError({ + code: "GIT_ACCOUNT_NOT_FOUND", + domain: "project", + summary: `Unknown GitHub account "${accountRef}"`, + why: "The account is not connectable to this workspace: it is either unknown, already connected here, or belongs to workspaces you are not a member of.", + fix: + connectable.length > 0 + ? "Pass one of the connectable accounts by login or installation id." + : "Install the Prisma GitHub App on the account first.", + exitCode: 1, + meta: { accountRef, connectable }, + nextSteps: + connectable.length > 0 + ? connectable.map((candidate) => + formatCommand([ + "git", + "account", + "connect", + candidate.accountLogin, + ]), + ) + : [formatCommand(["git", "account", "connect"])], + }); +} + +function gitInstallTimedOutError( + installUrl: string, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + return new CliError({ + code: "GIT_INSTALL_TIMED_OUT", + domain: "project", + summary: "Timed out waiting for the GitHub App install", + why: "The install did not complete before the wait timed out; it may still be in progress.", + fix: "Finish the install in your browser, then check the connected accounts.", + exitCode: 1, + meta: { installUrl }, + nextSteps: [formatCommand(["git", "account", "list"])], + }); +} + +function gitConnectAccountError( + response: Response | undefined, + error: SourceRepositoryApiError | undefined, + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { + if (response?.status === 422) { + return new CliError({ + code: "GIT_CONNECT_FAILED", + domain: "project", + summary: "GitHub reports the installation no longer exists", + why: "The Prisma GitHub App was uninstalled from this account out of band; the stale connection records were cleaned up.", + fix: "Reinstall the app on the GitHub account, then connect it again.", + exitCode: 1, + nextSteps: [formatCommand(["git", "account", "connect"])], + }); + } + return repoConnectionApiError( + "Failed to connect the GitHub account", + response, + error, + ); +} diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index a160ff9e..d98080a5 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -13,6 +13,8 @@ import { renderVerboseBlock, } from "../shell/ui"; import type { + GitAccountsResult, + GitConnectAccountResult, GitRepositoryConnection, ProjectListResult, ProjectRemoveResult, @@ -385,3 +387,64 @@ function formatGitConnectionDetail( return "GitHub repository is connected, but branch automation is not active."; } } + +export function renderGitAccountList( + context: CommandContext, + descriptor: CommandDescriptor, + result: GitAccountsResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("\u2502"); + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("\u2192")} ${ui.dim("GitHub accounts for the active workspace.")}`, + "", + `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, + rail, + ]; + + if (result.connected.length === 0) { + lines.push(`${rail} ${ui.dim("No GitHub account connected.")}`); + } else { + for (const account of result.connected) { + const status = account.suspended ? ui.dim(" (suspended)") : ""; + lines.push( + `${rail} ${ui.accent("connected:")} ${account.accountLogin} ${ui.dim(`(${account.accountType}, installation ${account.installationId})`)}${status}`, + ); + } + } + + if (result.connectable.length > 0) { + lines.push(rail); + for (const candidate of result.connectable) { + lines.push( + `${rail} ${ui.accent("connectable:")} ${candidate.accountLogin} ${ui.dim(`(installation ${candidate.installationId}, via another workspace)`)}`, + ); + } + } + + return lines; +} + +export function serializeGitAccountList(result: GitAccountsResult) { + return result; +} + +export function renderGitAccountConnect( + context: CommandContext, + descriptor: CommandDescriptor, + result: GitConnectAccountResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("\u2502"); + return [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("\u2192")} ${ui.dim(result.newlyInstalled ? "Installed and connected a GitHub account." : "Connected a GitHub account.")}`, + "", + `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, + `${rail} ${ui.accent("account:")} ${result.account.accountLogin}`, + `${rail} ${ui.accent("connected:")} yes`, + ]; +} + +export function serializeGitAccountConnect(result: GitConnectAccountResult) { + return result; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 1c0f50af..ece7b32b 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -297,6 +297,28 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli git disconnect --project proj_123", ], }, + { + id: "git.account", + path: ["prisma", "git", "account"], + description: "Manage the workspace's GitHub account connections", + examples: ["prisma-cli git account list"], + }, + { + id: "git.account.list", + path: ["prisma", "git", "account", "list"], + description: "List connected and connectable GitHub accounts", + examples: ["prisma-cli git account list"], + }, + { + id: "git.account.connect", + path: ["prisma", "git", "account", "connect"], + description: + "Connect a GitHub account to the workspace, or install a new one", + examples: [ + "prisma-cli git account connect", + "prisma-cli git account connect acme-org", + ], + }, { id: "branch.list", path: ["prisma", "branch", "list"], diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index fa424e3a..674c321b 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -134,3 +134,35 @@ export interface ProjectRepositoryConnectionResult extends BoundProjectShowResult { repositoryConnection: GitRepositoryConnection; } + +export interface GitAccountSummary { + /** Numeric GitHub App installation id. */ + installationId: number; + accountLogin: string; + accountType: "user" | "organization"; + suspended: boolean; +} + +export interface GitConnectableAccountSummary { + /** Numeric GitHub App installation id. */ + installationId: number; + accountLogin: string; +} + +export interface GitAccountsResult { + workspace: AuthWorkspace; + connected: GitAccountSummary[]; + connectable: GitConnectableAccountSummary[]; +} + +export interface GitConnectAccountResult { + workspace: AuthWorkspace; + account: GitAccountSummary; + /** True when the account was just installed via the browser, not connected from another workspace. */ + newlyInstalled: boolean; +} + +export interface GitInstallResult { + workspace: AuthWorkspace; + installUrl: string; +} diff --git a/packages/cli/tests/git-account.test.ts b/packages/cli/tests/git-account.test.ts new file mode 100644 index 00000000..a30cbc6c --- /dev/null +++ b/packages/cli/tests/git-account.test.ts @@ -0,0 +1,103 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +async function login(cwd: string, stateDir: string) { + await executeCli({ + argv: [ + "auth", + "login", + "--provider", + "github", + "--user", + "usr_123", + "--workspace", + "ws_123", + ], + cwd, + stateDir, + fixturePath, + }); +} + +async function run(argv: string[]) { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + return executeCli({ argv, cwd, stateDir, fixturePath, isTTY: false }); +} + +describe("git account commands", () => { + it("lists connected and connectable accounts as JSON", async () => { + const result = await run(["git", "account", "list", "--json"]); + + expect(result.exitCode).toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope.command).toBe("git.account.list"); + expect(envelope.result.connected).toEqual([ + { + installationId: 555001, + accountLogin: "acme-bot", + accountType: "organization", + suspended: false, + }, + ]); + // Two fixture rows share prisma-labs; the newest installation wins. + expect(envelope.result.connectable).toEqual([ + { installationId: 555003, accountLogin: "prisma-labs" }, + ]); + expect(result.stdout).toContain("git account connect prisma-labs"); + }); + + it("connects an account by login", async () => { + const result = await run([ + "git", + "account", + "connect", + "prisma-labs", + "--json", + ]); + + expect(result.exitCode).toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope.command).toBe("git.account.connect"); + expect(envelope.result.account).toEqual({ + installationId: 555003, + accountLogin: "prisma-labs", + accountType: "organization", + suspended: false, + }); + expect(envelope.result.newlyInstalled).toBe(false); + }); + + it("connects by numeric installation id", async () => { + const result = await run(["git", "account", "connect", "555003", "--json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).result.account.accountLogin).toBe( + "prisma-labs", + ); + }); + + it("returns the connectable options when no account is given and no TTY", async () => { + const result = await run(["git", "account", "connect", "--json"]); + + expect(result.exitCode).toBe(2); + const envelope = JSON.parse(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.error.code).toBe("GIT_ACCOUNT_REQUIRED"); + expect(envelope.error.meta.connectable).toEqual([ + { installationId: 555003, accountLogin: "prisma-labs" }, + ]); + expect(result.stdout).toContain("git account connect prisma-labs"); + }); + + it("fails with a structured error for an unknown account", async () => { + const result = await run(["git", "account", "connect", "nope", "--json"]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.code).toBe("GIT_ACCOUNT_NOT_FOUND"); + }); +});