From 3f65382c8ec60bef652e5bff846f421ad53032da Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 6 Jul 2026 23:15:15 +0530 Subject: [PATCH 1/3] feat(cli): add github command group for workspace GitHub connections Adds prisma-cli github list/connect/install so agents and headless sessions can drive the GitHub connection flow the Console gained in pdp-control-plane#4406: list connected and connectable accounts, connect an already-installed account to the active workspace without a GitHub round trip, and mint the install URL for brand-new accounts. Connect resolution is non-interactive by design: missing or unknown accounts fail with structured errors carrying the connectable options in meta and as runnable next steps. Real mode calls the Management API endpoints from pdp-control-plane#4408; the connectable/connect paths are called through a single typed seam until the published SDK carries them. Fixture mode is fully covered by tests. --- docs/product/command-spec.md | 57 +++++++ docs/product/error-conventions.md | 8 + packages/cli/fixtures/mock-api.json | 43 ++++- packages/cli/src/adapters/mock-api.ts | 74 +++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/github/index.ts | 121 ++++++++++++++ packages/cli/src/controllers/github.ts | 194 ++++++++++++++++++++++ packages/cli/src/lib/github/provider.ts | 177 ++++++++++++++++++++ packages/cli/src/presenters/github.ts | 90 ++++++++++ packages/cli/src/shell/command-meta.ts | 25 +++ packages/cli/src/shell/errors.ts | 3 +- packages/cli/src/types/github.ts | 31 ++++ packages/cli/tests/github.test.ts | 159 ++++++++++++++++++ 13 files changed, 979 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/github/index.ts create mode 100644 packages/cli/src/controllers/github.ts create mode 100644 packages/cli/src/lib/github/provider.ts create mode 100644 packages/cli/src/presenters/github.ts create mode 100644 packages/cli/src/types/github.ts create mode 100644 packages/cli/tests/github.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 2cb0705..9ae5c39 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -894,6 +894,63 @@ prisma-cli git disconnect --project proj_123 prisma-cli git disconnect --json ``` +## `prisma-cli github list` + +Purpose: + +- show the active workspace's GitHub connection state + +Behavior: + +- lists GitHub accounts connected to the active workspace (login, account type, numeric installation id, suspension state) +- lists accounts connectable without a GitHub round trip: installations of other workspaces the user belongs to, deduplicated per GitHub account with the newest installation winning +- suggests `github connect ` as a next step when connectable accounts exist + +Examples: + +```bash +prisma-cli github list +prisma-cli github list --json +``` + +## `prisma-cli github connect [account]` + +Purpose: + +- connect a GitHub account that already has the Prisma GitHub App installed to the active workspace, without a GitHub round trip + +Behavior: + +- resolves `[account]` by login or numeric installation id among the connectable accounts +- without `[account]`, fails with `GITHUB_ACCOUNT_REQUIRED`; the connectable accounts are listed in `error.meta.connectable` and as runnable next steps, so agents can pick without prompting +- an unknown account fails with `GITHUB_ACCOUNT_NOT_FOUND` and the same machine-readable options +- authorization is enforced by the platform: the user must be a member of a workspace the installation is already actively connected to +- if GitHub reports the installation gone, fails with `GITHUB_CONNECT_FAILED` after the platform cleans up its stale records + +Examples: + +```bash +prisma-cli github connect acme-org +prisma-cli github connect 555003 +``` + +## `prisma-cli github install` + +Purpose: + +- get the GitHub App install link for connecting a brand-new GitHub account + +Behavior: + +- prints a single-use, time-limited install URL bound to the active workspace +- the install completes in the browser; the Console callback finishes the connection + +Examples: + +```bash +prisma-cli github install +``` + ## `prisma-cli branch list` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index ef9fa9f..fcdc022 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` +- `GITHUB_ACCOUNT_REQUIRED` +- `GITHUB_ACCOUNT_NOT_FOUND` +- `GITHUB_CONNECT_FAILED` +- `GITHUB_API_ERROR` - `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 +- `GITHUB_ACCOUNT_REQUIRED`: `github connect` needs a GitHub account argument; connectable accounts are listed in `error.meta.connectable` and as next steps +- `GITHUB_ACCOUNT_NOT_FOUND`: the requested GitHub account is not connectable to the active workspace +- `GITHUB_CONNECT_FAILED`: GitHub reports the installation no longer exists; stale platform records were cleaned up +- `GITHUB_API_ERROR`: a GitHub-related Management API request failed without a more specific CLI error code - `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 6749e8b..fd397a9 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 8b43bc7..b875294 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/cli.ts b/packages/cli/src/cli.ts index 04dec58..21f09e3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { createBranchCommand } from "./commands/branch"; import { createBuildCommand } from "./commands/build"; import { createDatabaseCommand } from "./commands/database"; import { createGitCommand } from "./commands/git"; +import { createGithubCommand } from "./commands/github"; import { createInitCommand } from "./commands/init"; import { createProjectCommand } from "./commands/project"; import { createVersionCommand } from "./commands/version"; @@ -90,6 +91,7 @@ export function createProgram(runtime: CliRuntime): Command { program.addCommand(createProjectCommand(runtime)); program.addCommand(createGitCommand(runtime)); program.addCommand(createBranchCommand(runtime)); + program.addCommand(createGithubCommand(runtime)); program.addCommand(createBuildCommand(runtime)); program.addCommand(createDatabaseCommand(runtime)); program.addCommand(createAppCommand(runtime)); diff --git a/packages/cli/src/commands/github/index.ts b/packages/cli/src/commands/github/index.ts new file mode 100644 index 0000000..ef1a9e4 --- /dev/null +++ b/packages/cli/src/commands/github/index.ts @@ -0,0 +1,121 @@ +import { Command } from "commander"; + +import { + runGithubConnect, + runGithubInstall, + runGithubList, +} from "../../controllers/github"; +import { + renderGithubConnect, + renderGithubInstall, + renderGithubList, + serializeGithubConnect, + serializeGithubInstall, + serializeGithubList, +} from "../../presenters/github"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runCommand } from "../../shell/command-runner"; +import { + addCompactGlobalFlags, + addGlobalFlags, +} from "../../shell/global-flags"; +import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; +import type { + GithubConnectResult, + GithubInstallResult, + GithubListResult, +} from "../../types/github"; + +export function createGithubCommand(runtime: CliRuntime): Command { + const github = attachCommandDescriptor( + configureRuntimeCommand(new Command("github"), runtime), + "github", + ); + + addCompactGlobalFlags(github); + + github.addCommand(createListCommand(runtime)); + github.addCommand(createConnectCommand(runtime)); + github.addCommand(createInstallCommand(runtime)); + + return github; +} + +function createListCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("list"), runtime), + "github.list", + ); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "github.list", + options as Record, + (context) => runGithubList(context), + { + renderHuman: (context, descriptor, result) => + renderGithubList(context, descriptor, result), + renderJson: (result) => serializeGithubList(result), + }, + ); + }); + + return command; +} + +function createConnectCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("connect"), runtime), + "github.connect", + ); + + command.argument( + "[account]", + "GitHub account login or numeric installation id", + ); + addGlobalFlags(command); + + command.action(async (account: string | undefined, options) => { + await runCommand( + runtime, + "github.connect", + options as Record, + (context) => runGithubConnect(context, account), + { + renderHuman: (context, descriptor, result) => + renderGithubConnect(context, descriptor, result), + renderJson: (result) => serializeGithubConnect(result), + }, + ); + }); + + return command; +} + +function createInstallCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("install"), runtime), + "github.install", + ); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "github.install", + options as Record, + (context) => runGithubInstall(context), + { + renderHuman: (context, descriptor, result) => + renderGithubInstall(context, descriptor, result), + renderJson: (result) => serializeGithubInstall(result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/controllers/github.ts b/packages/cli/src/controllers/github.ts new file mode 100644 index 0000000..f680aa9 --- /dev/null +++ b/packages/cli/src/controllers/github.ts @@ -0,0 +1,194 @@ +import { resolvePrismaCliPackageCommandFormatterSync } from "../lib/agent/cli-command"; +import { requireComputeAuth } from "../lib/auth/guard"; +import { createGithubProvider } from "../lib/github/provider"; +import { + authRequiredError, + CliError, + workspaceRequiredError, +} from "../shell/errors"; +import type { CommandSuccess } from "../shell/output"; +import type { CommandContext } from "../shell/runtime"; +import type { + GithubConnectableSummary, + GithubConnectResult, + GithubInstallResult, + GithubListResult, +} from "../types/github"; +import { requireAuthenticatedAuthState } from "./auth"; + +function isRealMode(context: CommandContext): boolean { + return ( + !context.runtime.fixturePath && + !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH + ); +} + +async function resolveWorkspaceAndProvider(context: CommandContext) { + 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, provider: null }; + } + + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); + } + return { + workspace, + formatCommand, + provider: createGithubProvider(client, { + formatCommand, + signal: context.runtime.signal, + }), + }; +} + +export async function runGithubList( + context: CommandContext, +): Promise> { + const { workspace, provider, formatCommand } = + await resolveWorkspaceAndProvider(context); + + const [connected, connectable] = provider + ? await Promise.all([ + provider.listInstallations(workspace.id), + provider.listConnectable(workspace.id), + ]) + : [ + context.api.listScmInstallations(workspace.id), + context.api.listConnectableScmInstallations(workspace.id), + ]; + + return { + command: "github.list", + result: { workspace, connected, connectable }, + warnings: [], + nextSteps: + connectable.length > 0 + ? [formatCommand(["github", "connect", connectable[0].accountLogin])] + : [], + }; +} + +export async function runGithubConnect( + context: CommandContext, + accountRef: string | undefined, +): Promise> { + const { workspace, provider, formatCommand } = + await resolveWorkspaceAndProvider(context); + + const connectable = provider + ? await provider.listConnectable(workspace.id) + : context.api.listConnectableScmInstallations(workspace.id); + + if (!accountRef) { + throw githubAccountRequiredError(connectable, formatCommand); + } + + const target = resolveConnectableAccount(accountRef, connectable); + if (!target) { + throw githubAccountNotFoundError(accountRef, connectable, formatCommand); + } + + const installation = provider + ? await provider.connect(workspace.id, target.installationId) + : context.api.connectScmInstallation(workspace.id, target.installationId); + + return { + command: "github.connect", + result: { workspace, installation }, + warnings: [], + nextSteps: [formatCommand(["github", "list"])], + }; +} + +export async function runGithubInstall( + context: CommandContext, +): Promise> { + const { workspace, provider } = await resolveWorkspaceAndProvider(context); + + const installUrl = provider + ? await provider.createInstallIntent(workspace.id) + : `https://github.com/apps/prisma/installations/new?state=fixture-nonce`; + + return { + command: "github.install", + result: { workspace, installUrl }, + warnings: [], + nextSteps: [], + }; +} + +function resolveConnectableAccount( + accountRef: string, + connectable: GithubConnectableSummary[], +): GithubConnectableSummary | undefined { + return connectable.find( + (candidate) => + candidate.accountLogin === accountRef || + String(candidate.installationId) === accountRef, + ); +} + +function githubAccountRequiredError( + connectable: GithubConnectableSummary[], + formatCommand: (args: string[]) => string, +): CliError { + return new CliError({ + code: "GITHUB_ACCOUNT_REQUIRED", + domain: "github", + summary: "GitHub account required", + why: + connectable.length > 0 + ? "Pass the GitHub account to connect to this workspace." + : "No GitHub account is connectable to this workspace; installations of other workspaces you belong to would appear here.", + fix: + connectable.length > 0 + ? "Rerun with one of the connectable accounts." + : "Install the Prisma GitHub App first, then connect it.", + exitCode: 2, + meta: { connectable }, + nextSteps: + connectable.length > 0 + ? connectable.map((candidate) => + formatCommand(["github", "connect", candidate.accountLogin]), + ) + : [formatCommand(["github", "install"])], + }); +} + +function githubAccountNotFoundError( + accountRef: string, + connectable: GithubConnectableSummary[], + formatCommand: (args: string[]) => string, +): CliError { + return new CliError({ + code: "GITHUB_ACCOUNT_NOT_FOUND", + domain: "github", + 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(["github", "connect", candidate.accountLogin]), + ) + : [formatCommand(["github", "install"])], + }); +} diff --git a/packages/cli/src/lib/github/provider.ts b/packages/cli/src/lib/github/provider.ts new file mode 100644 index 0000000..fed7797 --- /dev/null +++ b/packages/cli/src/lib/github/provider.ts @@ -0,0 +1,177 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { CliError } from "../../shell/errors"; +import type { + GithubConnectableSummary, + GithubInstallationSummary, +} from "../../types/github"; + +export interface GithubProvider { + listInstallations(workspaceId: string): Promise; + listConnectable(workspaceId: string): Promise; + connect( + workspaceId: string, + installationId: number, + ): Promise; + createInstallIntent(workspaceId: string): Promise; +} + +// The connectable/connect endpoints shipped in the Management API but the +// published @prisma/management-api-sdk does not carry their path types yet. +// This is the single seam that calls them with locally declared shapes; +// delete it and use the typed client once the SDK regen lands. +interface ExperimentalPathsClient { + GET( + path: "/v1/scm-installations/connectable", + init: { + params: { query: { workspaceId: string } }; + signal?: AbortSignal; + }, + ): Promise<{ + data?: { data: GithubConnectableSummary[] }; + response: Response; + }>; + POST( + path: "/v1/scm-installations/connect", + init: { + body: { + provider: "github"; + workspaceId: string; + installationId: number; + }; + signal?: AbortSignal; + }, + ): Promise<{ + data?: { data: RawScmInstallation }; + response: Response; + }>; +} + +interface RawScmInstallation { + installationId: number; + accountLogin: string; + accountType: "user" | "organization"; + suspended: boolean; +} + +function stripWorkspacePrefix(workspaceId: string): string { + return workspaceId.replace(/^ws_/, ""); +} + +export function createGithubProvider( + client: ManagementApiClient, + options: { formatCommand: (args: string[]) => string; signal?: AbortSignal }, +): GithubProvider { + const experimental = client as unknown as ExperimentalPathsClient; + const { formatCommand, signal } = options; + + return { + async listInstallations(workspaceId) { + const result = await client.GET("/v1/scm-installations", { + params: { + query: { workspaceId: stripWorkspacePrefix(workspaceId), limit: 100 }, + }, + signal, + }); + if (!result.data) { + throw githubApiError("list GitHub installations", result.response); + } + return result.data.data.map((record) => ({ + installationId: record.installationId, + accountLogin: record.accountLogin, + accountType: record.accountType, + suspended: record.suspended, + })); + }, + + async listConnectable(workspaceId) { + const result = await experimental.GET( + "/v1/scm-installations/connectable", + { + params: { + query: { workspaceId: stripWorkspacePrefix(workspaceId) }, + }, + signal, + }, + ); + if (!result.data) { + throw githubApiError( + "list connectable GitHub installations", + result.response, + ); + } + return result.data.data.map((record) => ({ + installationId: record.installationId, + accountLogin: record.accountLogin, + })); + }, + + async connect(workspaceId, installationId) { + const result = await experimental.POST("/v1/scm-installations/connect", { + body: { + provider: "github", + workspaceId: stripWorkspacePrefix(workspaceId), + installationId, + }, + signal, + }); + if (!result.data) { + throw githubConnectError(result.response, formatCommand); + } + const record = result.data.data; + return { + installationId: record.installationId, + accountLogin: record.accountLogin, + accountType: record.accountType, + suspended: record.suspended, + }; + }, + + async createInstallIntent(workspaceId) { + const result = await client.POST( + "/v1/scm-installations/install-intents", + { + body: { + provider: "github", + workspaceId: stripWorkspacePrefix(workspaceId), + }, + signal, + }, + ); + if (!result.data) { + throw githubApiError("create a GitHub install intent", result.response); + } + return result.data.data.installUrl; + }, + }; +} + +function githubConnectError( + response: Response, + formatCommand: (args: string[]) => string, +): CliError { + if (response.status === 422) { + return new CliError({ + code: "GITHUB_CONNECT_FAILED", + domain: "github", + 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(["github", "install"])], + }); + } + return githubApiError("connect the GitHub installation", response); +} + +function githubApiError(action: string, response: Response): CliError { + return new CliError({ + code: "GITHUB_API_ERROR", + domain: "github", + summary: `Could not ${action}`, + why: `The Platform API responded with status ${response.status}.`, + fix: "Retry; if the problem persists, check the Console or contact support.", + exitCode: 1, + meta: { status: response.status }, + }); +} diff --git a/packages/cli/src/presenters/github.ts b/packages/cli/src/presenters/github.ts new file mode 100644 index 0000000..3a83a63 --- /dev/null +++ b/packages/cli/src/presenters/github.ts @@ -0,0 +1,90 @@ +import type { CommandDescriptor } from "../shell/command-meta"; +import { formatDescriptorLabel } from "../shell/command-meta"; +import type { CommandContext } from "../shell/runtime"; +import type { + GithubConnectResult, + GithubInstallResult, + GithubListResult, +} from "../types/github"; + +export function renderGithubList( + context: CommandContext, + descriptor: CommandDescriptor, + result: GithubListResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("GitHub installations 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 installation of result.connected) { + const status = installation.suspended ? ui.dim(" (suspended)") : ""; + lines.push( + `${rail} ${ui.accent("connected:")} ${installation.accountLogin} ${ui.dim(`(${installation.accountType}, installation ${installation.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 serializeGithubList(result: GithubListResult) { + return result; +} + +export function renderGithubConnect( + context: CommandContext, + descriptor: CommandDescriptor, + result: GithubConnectResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + return [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Connecting a GitHub account.")}`, + "", + `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, + `${rail} ${ui.accent("account:")} ${result.installation.accountLogin}`, + `${rail} ${ui.accent("connected:")} yes`, + ]; +} + +export function serializeGithubConnect(result: GithubConnectResult) { + return result; +} + +export function renderGithubInstall( + context: CommandContext, + descriptor: CommandDescriptor, + result: GithubInstallResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + return [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Install the Prisma GitHub App.")}`, + "", + `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, + `${rail} ${ui.accent("open:")} ${result.installUrl}`, + rail, + `${rail} ${ui.dim("Finish the installation on GitHub; the Console completes the connection.")}`, + ]; +} + +export function serializeGithubInstall(result: GithubInstallResult) { + return result; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 1c0f50a..a0c25b6 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -188,6 +188,31 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "View your Platform branches", examples: ["prisma-cli branch list"], }, + { + id: "github", + path: ["prisma", "github"], + description: "Manage the workspace's GitHub connection", + examples: ["prisma-cli github list"], + }, + { + id: "github.list", + path: ["prisma", "github", "list"], + description: "List connected and connectable GitHub accounts", + examples: ["prisma-cli github list"], + }, + { + id: "github.connect", + path: ["prisma", "github", "connect"], + description: + "Connect a GitHub account already installed via another workspace", + examples: ["prisma-cli github connect acme-org"], + }, + { + id: "github.install", + path: ["prisma", "github", "install"], + description: "Get the Prisma GitHub App install link", + examples: ["prisma-cli github install"], + }, { id: "build", path: ["prisma", "build"], diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index 4a45beb..de7fa6b 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -6,7 +6,8 @@ export type ErrorDomain = | "project" | "branch" | "app" - | "database"; + | "database" + | "github"; export type ErrorSeverity = "error"; export interface CliErrorOptions { diff --git a/packages/cli/src/types/github.ts b/packages/cli/src/types/github.ts new file mode 100644 index 0000000..2b0020d --- /dev/null +++ b/packages/cli/src/types/github.ts @@ -0,0 +1,31 @@ +import type { AuthWorkspace } from "./auth"; + +export interface GithubInstallationSummary { + /** Numeric GitHub App installation id. */ + installationId: number; + accountLogin: string; + accountType: "user" | "organization"; + suspended: boolean; +} + +export interface GithubConnectableSummary { + /** Numeric GitHub App installation id. */ + installationId: number; + accountLogin: string; +} + +export interface GithubListResult { + workspace: AuthWorkspace; + connected: GithubInstallationSummary[]; + connectable: GithubConnectableSummary[]; +} + +export interface GithubConnectResult { + workspace: AuthWorkspace; + installation: GithubInstallationSummary; +} + +export interface GithubInstallResult { + workspace: AuthWorkspace; + installUrl: string; +} diff --git a/packages/cli/tests/github.test.ts b/packages/cli/tests/github.test.ts new file mode 100644 index 0000000..d3c123d --- /dev/null +++ b/packages/cli/tests/github.test.ts @@ -0,0 +1,159 @@ +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, + }); +} + +describe("github commands", () => { + it("lists connected and connectable accounts as JSON", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + const result = await executeCli({ + argv: ["github", "list", "--json"], + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + expect(result.exitCode).toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope.ok).toBe(true); + expect(envelope.command).toBe("github.list"); + expect(envelope.result.connected).toEqual([ + { + installationId: 555001, + accountLogin: "acme-bot", + accountType: "organization", + suspended: false, + }, + ]); + // Two fixture rows share the prisma-labs account; the newest wins. + expect(envelope.result.connectable).toEqual([ + { installationId: 555003, accountLogin: "prisma-labs" }, + ]); + expect(envelope.nextSteps.join(" ")).toContain( + "github connect prisma-labs", + ); + }); + + it("connects a connectable account by login and drops it from connectable", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + const result = await executeCli({ + argv: ["github", "connect", "prisma-labs", "--json"], + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + expect(result.exitCode).toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope.ok).toBe(true); + expect(envelope.command).toBe("github.connect"); + expect(envelope.result.installation).toEqual({ + installationId: 555003, + accountLogin: "prisma-labs", + accountType: "organization", + suspended: false, + }); + }); + + it("connects by numeric installation id", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + const result = await executeCli({ + argv: ["github", "connect", "555003", "--json"], + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).result.installation.accountLogin).toBe( + "prisma-labs", + ); + }); + + it("requires an account argument and lists the connectable options", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + const result = await executeCli({ + argv: ["github", "connect", "--json"], + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + expect(result.exitCode).toBe(2); + const envelope = JSON.parse(result.stdout); + expect(envelope.ok).toBe(false); + expect(envelope.error.code).toBe("GITHUB_ACCOUNT_REQUIRED"); + expect(envelope.error.meta.connectable).toEqual([ + { installationId: 555003, accountLogin: "prisma-labs" }, + ]); + expect(result.stdout).toContain("github connect prisma-labs"); + }); + + it("fails with a structured error for an unknown account", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + const result = await executeCli({ + argv: ["github", "connect", "nope", "--json"], + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + expect(result.exitCode).toBe(1); + const envelope = JSON.parse(result.stdout); + expect(envelope.error.code).toBe("GITHUB_ACCOUNT_NOT_FOUND"); + expect(envelope.error.meta.connectable).toEqual([ + { installationId: 555003, accountLogin: "prisma-labs" }, + ]); + }); + + it("prints the install URL", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + const result = await executeCli({ + argv: ["github", "install", "--json"], + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + expect(result.exitCode).toBe(0); + const envelope = JSON.parse(result.stdout); + expect(envelope.result.installUrl).toContain("github.com/apps/"); + }); +}); From 80b4cbf82288a88dd5bac16dfd4d4598b681983d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 7 Jul 2026 14:59:51 +0530 Subject: [PATCH 2/3] refactor(cli): fold GitHub account connect into the existing git group Replaces the separate github command group with three subcommands under the existing git group, so all GitHub functionality lives in one place: git accounts (list connected + connectable), git connect-account (connect an already-installed account from another workspace), git install (install link). git connect/disconnect keep owning the repo-to-project link. Reuses the existing source-repository API seam and install-intent helper; error codes are GIT_ACCOUNT_REQUIRED / GIT_ACCOUNT_NOT_FOUND / GIT_CONNECT_FAILED. --- docs/product/command-spec.md | 38 ++- docs/product/error-conventions.md | 14 +- packages/cli/src/cli.ts | 2 - packages/cli/src/commands/git/index.ts | 103 +++++- packages/cli/src/commands/github/index.ts | 121 -------- packages/cli/src/controllers/github.ts | 194 ------------ packages/cli/src/controllers/project.ts | 292 ++++++++++++++++++ packages/cli/src/lib/github/provider.ts | 177 ----------- packages/cli/src/presenters/github.ts | 90 ------ packages/cli/src/presenters/project.ts | 85 +++++ packages/cli/src/shell/command-meta.ts | 44 ++- packages/cli/src/shell/errors.ts | 3 +- packages/cli/src/types/github.ts | 31 -- packages/cli/src/types/project.ts | 30 ++ .../{github.test.ts => git-accounts.test.ts} | 46 ++- 15 files changed, 575 insertions(+), 695 deletions(-) delete mode 100644 packages/cli/src/commands/github/index.ts delete mode 100644 packages/cli/src/controllers/github.ts delete mode 100644 packages/cli/src/lib/github/provider.ts delete mode 100644 packages/cli/src/presenters/github.ts delete mode 100644 packages/cli/src/types/github.ts rename packages/cli/tests/{github.test.ts => git-accounts.test.ts} (70%) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 9ae5c39..0d8cf2a 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -894,61 +894,65 @@ prisma-cli git disconnect --project proj_123 prisma-cli git disconnect --json ``` -## `prisma-cli github list` +## `prisma-cli git accounts` Purpose: -- show the active workspace's GitHub connection state +- show the active workspace's GitHub account connections Behavior: +- requires auth - lists GitHub accounts connected to the active workspace (login, account type, numeric installation id, suspension state) - lists accounts connectable without a GitHub round trip: installations of other workspaces the user belongs to, deduplicated per GitHub account with the newest installation winning -- suggests `github connect ` as a next step when connectable accounts exist +- suggests `git connect-account ` as a next step when connectable accounts exist Examples: ```bash -prisma-cli github list -prisma-cli github list --json +prisma-cli git accounts +prisma-cli git accounts --json ``` -## `prisma-cli github connect [account]` +## `prisma-cli git connect-account [account]` Purpose: -- connect a GitHub account that already has the Prisma GitHub App installed to the active workspace, without a GitHub round trip +- connect a GitHub account that already has the Prisma GitHub App installed (via another workspace) to the active workspace, without a GitHub round trip Behavior: +- requires auth - resolves `[account]` by login or numeric installation id among the connectable accounts -- without `[account]`, fails with `GITHUB_ACCOUNT_REQUIRED`; the connectable accounts are listed in `error.meta.connectable` and as runnable next steps, so agents can pick without prompting -- an unknown account fails with `GITHUB_ACCOUNT_NOT_FOUND` and the same machine-readable options -- authorization is enforced by the platform: the user must be a member of a workspace the installation is already actively connected to -- if GitHub reports the installation gone, fails with `GITHUB_CONNECT_FAILED` after the platform cleans up its stale records +- without `[account]`, fails with `GIT_ACCOUNT_REQUIRED`; the connectable accounts are listed in `error.meta.connectable` and as runnable next steps, so agents can pick without prompting +- 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 installation is already actively connected to +- if GitHub reports the installation gone, fails with `GIT_CONNECT_FAILED` after the platform cleans up its stale records +- this is the account-level connection; link a repository to a project with `git connect` Examples: ```bash -prisma-cli github connect acme-org -prisma-cli github connect 555003 +prisma-cli git connect-account acme-org +prisma-cli git connect-account 555003 ``` -## `prisma-cli github install` +## `prisma-cli git install` Purpose: -- get the GitHub App install link for connecting a brand-new GitHub account +- get the Prisma GitHub App install link for a brand-new GitHub account Behavior: +- requires auth - prints a single-use, time-limited install URL bound to the active workspace -- the install completes in the browser; the Console callback finishes the connection +- the install completes in the browser; then connect a repository with `git connect` Examples: ```bash -prisma-cli github install +prisma-cli git install ``` ## `prisma-cli branch list` diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index fcdc022..20facd2 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -181,10 +181,9 @@ These codes are the minimum stable set for the MVP: - `LOCAL_STATE_WRITE_FAILED` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` -- `GITHUB_ACCOUNT_REQUIRED` -- `GITHUB_ACCOUNT_NOT_FOUND` -- `GITHUB_CONNECT_FAILED` -- `GITHUB_API_ERROR` +- `GIT_ACCOUNT_REQUIRED` +- `GIT_ACCOUNT_NOT_FOUND` +- `GIT_CONNECT_FAILED` - `COMPUTE_CONFIG_INVALID` - `COMPUTE_CONFIG_TARGET_REQUIRED` - `COMPUTE_CONFIG_TARGET_UNKNOWN` @@ -258,10 +257,9 @@ 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 -- `GITHUB_ACCOUNT_REQUIRED`: `github connect` needs a GitHub account argument; connectable accounts are listed in `error.meta.connectable` and as next steps -- `GITHUB_ACCOUNT_NOT_FOUND`: the requested GitHub account is not connectable to the active workspace -- `GITHUB_CONNECT_FAILED`: GitHub reports the installation no longer exists; stale platform records were cleaned up -- `GITHUB_API_ERROR`: a GitHub-related Management API request failed without a more specific CLI error code +- `GIT_ACCOUNT_REQUIRED`: `git connect-account` needs a GitHub account argument; connectable accounts are listed in `error.meta.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 - `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/src/cli.ts b/packages/cli/src/cli.ts index 21f09e3..04dec58 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,7 +9,6 @@ import { createBranchCommand } from "./commands/branch"; import { createBuildCommand } from "./commands/build"; import { createDatabaseCommand } from "./commands/database"; import { createGitCommand } from "./commands/git"; -import { createGithubCommand } from "./commands/github"; import { createInitCommand } from "./commands/init"; import { createProjectCommand } from "./commands/project"; import { createVersionCommand } from "./commands/version"; @@ -91,7 +90,6 @@ export function createProgram(runtime: CliRuntime): Command { program.addCommand(createProjectCommand(runtime)); program.addCommand(createGitCommand(runtime)); program.addCommand(createBranchCommand(runtime)); - program.addCommand(createGithubCommand(runtime)); program.addCommand(createBuildCommand(runtime)); program.addCommand(createDatabaseCommand(runtime)); program.addCommand(createAppCommand(runtime)); diff --git a/packages/cli/src/commands/git/index.ts b/packages/cli/src/commands/git/index.ts index 8d396e3..5a49437 100644 --- a/packages/cli/src/commands/git/index.ts +++ b/packages/cli/src/commands/git/index.ts @@ -1,9 +1,21 @@ import { Command } from "commander"; -import { runGitConnect, runGitDisconnect } from "../../controllers/project"; import { + runGitAccounts, + runGitConnect, + runGitConnectAccount, + runGitDisconnect, + runGitInstall, +} from "../../controllers/project"; +import { + renderGitAccounts, renderGitConnect, + renderGitConnectAccount, renderGitDisconnect, + renderGitInstall, + serializeGitAccounts, + serializeGitConnectAccount, + serializeGitInstall, } from "../../presenters/project"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { runCommand } from "../../shell/command-runner"; @@ -12,7 +24,12 @@ import { addGlobalFlags, } from "../../shell/global-flags"; import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; -import type { ProjectRepositoryConnectionResult } from "../../types/project"; +import type { + GitAccountsResult, + GitConnectAccountResult, + GitInstallResult, + ProjectRepositoryConnectionResult, +} from "../../types/project"; export function createGitCommand(runtime: CliRuntime): Command { const git = attachCommandDescriptor( @@ -24,6 +41,9 @@ export function createGitCommand(runtime: CliRuntime): Command { git.addCommand(createGitConnectCommand(runtime)); git.addCommand(createGitDisconnectCommand(runtime)); + git.addCommand(createGitAccountsCommand(runtime)); + git.addCommand(createGitConnectAccountCommand(runtime)); + git.addCommand(createGitInstallCommand(runtime)); return git; } @@ -86,3 +106,82 @@ function createGitDisconnectCommand(runtime: CliRuntime): Command { return command; } + +function createGitAccountsCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("accounts"), runtime), + "git.accounts", + ); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "git.accounts", + options as Record, + (context) => runGitAccounts(context), + { + renderHuman: (context, descriptor, result) => + renderGitAccounts(context, descriptor, result), + renderJson: (result) => serializeGitAccounts(result), + }, + ); + }); + + return command; +} + +function createGitConnectAccountCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("connect-account"), runtime), + "git.connect-account", + ); + + command.argument( + "[account]", + "GitHub account login or numeric installation id", + ); + addGlobalFlags(command); + + command.action(async (account: string | undefined, options) => { + await runCommand( + runtime, + "git.connect-account", + options as Record, + (context) => runGitConnectAccount(context, account), + { + renderHuman: (context, descriptor, result) => + renderGitConnectAccount(context, descriptor, result), + renderJson: (result) => serializeGitConnectAccount(result), + }, + ); + }); + + return command; +} + +function createGitInstallCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("install"), runtime), + "git.install", + ); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "git.install", + options as Record, + (context) => runGitInstall(context), + { + renderHuman: (context, descriptor, result) => + renderGitInstall(context, descriptor, result), + renderJson: (result) => serializeGitInstall(result), + }, + ); + }); + + return command; +} diff --git a/packages/cli/src/commands/github/index.ts b/packages/cli/src/commands/github/index.ts deleted file mode 100644 index ef1a9e4..0000000 --- a/packages/cli/src/commands/github/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Command } from "commander"; - -import { - runGithubConnect, - runGithubInstall, - runGithubList, -} from "../../controllers/github"; -import { - renderGithubConnect, - renderGithubInstall, - renderGithubList, - serializeGithubConnect, - serializeGithubInstall, - serializeGithubList, -} from "../../presenters/github"; -import { attachCommandDescriptor } from "../../shell/command-meta"; -import { runCommand } from "../../shell/command-runner"; -import { - addCompactGlobalFlags, - addGlobalFlags, -} from "../../shell/global-flags"; -import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; -import type { - GithubConnectResult, - GithubInstallResult, - GithubListResult, -} from "../../types/github"; - -export function createGithubCommand(runtime: CliRuntime): Command { - const github = attachCommandDescriptor( - configureRuntimeCommand(new Command("github"), runtime), - "github", - ); - - addCompactGlobalFlags(github); - - github.addCommand(createListCommand(runtime)); - github.addCommand(createConnectCommand(runtime)); - github.addCommand(createInstallCommand(runtime)); - - return github; -} - -function createListCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor( - configureRuntimeCommand(new Command("list"), runtime), - "github.list", - ); - - addGlobalFlags(command); - - command.action(async (options) => { - await runCommand( - runtime, - "github.list", - options as Record, - (context) => runGithubList(context), - { - renderHuman: (context, descriptor, result) => - renderGithubList(context, descriptor, result), - renderJson: (result) => serializeGithubList(result), - }, - ); - }); - - return command; -} - -function createConnectCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor( - configureRuntimeCommand(new Command("connect"), runtime), - "github.connect", - ); - - command.argument( - "[account]", - "GitHub account login or numeric installation id", - ); - addGlobalFlags(command); - - command.action(async (account: string | undefined, options) => { - await runCommand( - runtime, - "github.connect", - options as Record, - (context) => runGithubConnect(context, account), - { - renderHuman: (context, descriptor, result) => - renderGithubConnect(context, descriptor, result), - renderJson: (result) => serializeGithubConnect(result), - }, - ); - }); - - return command; -} - -function createInstallCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor( - configureRuntimeCommand(new Command("install"), runtime), - "github.install", - ); - - addGlobalFlags(command); - - command.action(async (options) => { - await runCommand( - runtime, - "github.install", - options as Record, - (context) => runGithubInstall(context), - { - renderHuman: (context, descriptor, result) => - renderGithubInstall(context, descriptor, result), - renderJson: (result) => serializeGithubInstall(result), - }, - ); - }); - - return command; -} diff --git a/packages/cli/src/controllers/github.ts b/packages/cli/src/controllers/github.ts deleted file mode 100644 index f680aa9..0000000 --- a/packages/cli/src/controllers/github.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { resolvePrismaCliPackageCommandFormatterSync } from "../lib/agent/cli-command"; -import { requireComputeAuth } from "../lib/auth/guard"; -import { createGithubProvider } from "../lib/github/provider"; -import { - authRequiredError, - CliError, - workspaceRequiredError, -} from "../shell/errors"; -import type { CommandSuccess } from "../shell/output"; -import type { CommandContext } from "../shell/runtime"; -import type { - GithubConnectableSummary, - GithubConnectResult, - GithubInstallResult, - GithubListResult, -} from "../types/github"; -import { requireAuthenticatedAuthState } from "./auth"; - -function isRealMode(context: CommandContext): boolean { - return ( - !context.runtime.fixturePath && - !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH - ); -} - -async function resolveWorkspaceAndProvider(context: CommandContext) { - 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, provider: null }; - } - - const client = await requireComputeAuth( - context.runtime.env, - context.runtime.signal, - ); - if (!client) { - throw authRequiredError(["prisma-cli auth login"]); - } - return { - workspace, - formatCommand, - provider: createGithubProvider(client, { - formatCommand, - signal: context.runtime.signal, - }), - }; -} - -export async function runGithubList( - context: CommandContext, -): Promise> { - const { workspace, provider, formatCommand } = - await resolveWorkspaceAndProvider(context); - - const [connected, connectable] = provider - ? await Promise.all([ - provider.listInstallations(workspace.id), - provider.listConnectable(workspace.id), - ]) - : [ - context.api.listScmInstallations(workspace.id), - context.api.listConnectableScmInstallations(workspace.id), - ]; - - return { - command: "github.list", - result: { workspace, connected, connectable }, - warnings: [], - nextSteps: - connectable.length > 0 - ? [formatCommand(["github", "connect", connectable[0].accountLogin])] - : [], - }; -} - -export async function runGithubConnect( - context: CommandContext, - accountRef: string | undefined, -): Promise> { - const { workspace, provider, formatCommand } = - await resolveWorkspaceAndProvider(context); - - const connectable = provider - ? await provider.listConnectable(workspace.id) - : context.api.listConnectableScmInstallations(workspace.id); - - if (!accountRef) { - throw githubAccountRequiredError(connectable, formatCommand); - } - - const target = resolveConnectableAccount(accountRef, connectable); - if (!target) { - throw githubAccountNotFoundError(accountRef, connectable, formatCommand); - } - - const installation = provider - ? await provider.connect(workspace.id, target.installationId) - : context.api.connectScmInstallation(workspace.id, target.installationId); - - return { - command: "github.connect", - result: { workspace, installation }, - warnings: [], - nextSteps: [formatCommand(["github", "list"])], - }; -} - -export async function runGithubInstall( - context: CommandContext, -): Promise> { - const { workspace, provider } = await resolveWorkspaceAndProvider(context); - - const installUrl = provider - ? await provider.createInstallIntent(workspace.id) - : `https://github.com/apps/prisma/installations/new?state=fixture-nonce`; - - return { - command: "github.install", - result: { workspace, installUrl }, - warnings: [], - nextSteps: [], - }; -} - -function resolveConnectableAccount( - accountRef: string, - connectable: GithubConnectableSummary[], -): GithubConnectableSummary | undefined { - return connectable.find( - (candidate) => - candidate.accountLogin === accountRef || - String(candidate.installationId) === accountRef, - ); -} - -function githubAccountRequiredError( - connectable: GithubConnectableSummary[], - formatCommand: (args: string[]) => string, -): CliError { - return new CliError({ - code: "GITHUB_ACCOUNT_REQUIRED", - domain: "github", - summary: "GitHub account required", - why: - connectable.length > 0 - ? "Pass the GitHub account to connect to this workspace." - : "No GitHub account is connectable to this workspace; installations of other workspaces you belong to would appear here.", - fix: - connectable.length > 0 - ? "Rerun with one of the connectable accounts." - : "Install the Prisma GitHub App first, then connect it.", - exitCode: 2, - meta: { connectable }, - nextSteps: - connectable.length > 0 - ? connectable.map((candidate) => - formatCommand(["github", "connect", candidate.accountLogin]), - ) - : [formatCommand(["github", "install"])], - }); -} - -function githubAccountNotFoundError( - accountRef: string, - connectable: GithubConnectableSummary[], - formatCommand: (args: string[]) => string, -): CliError { - return new CliError({ - code: "GITHUB_ACCOUNT_NOT_FOUND", - domain: "github", - 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(["github", "connect", candidate.accountLogin]), - ) - : [formatCommand(["github", "install"])], - }); -} diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index a003332..80a6d6f 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -73,6 +73,11 @@ 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 +1611,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 +2322,259 @@ 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 runGitAccounts( + 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.accounts", + result: { workspace, connected, connectable }, + warnings: [], + nextSteps: + connectable.length > 0 + ? [ + formatCommand([ + "git", + "connect-account", + connectable[0].accountLogin, + ]), + ] + : [], + }; +} + +export async function runGitConnectAccount( + 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); + + if (!accountRef) { + throw gitAccountRequiredError(connectable, formatCommand); + } + + const target = connectable.find( + (candidate) => + candidate.accountLogin === accountRef || + String(candidate.installationId) === accountRef, + ); + if (!target) { + throw gitAccountNotFoundError(accountRef, connectable, formatCommand); + } + + let account: GitAccountSummary; + if (api) { + const { data, error, response } = await api.POST( + "/v1/scm-installations/connect", + { + body: { + provider: "github", + workspaceId: workspace.id, + installationId: target.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, target.installationId), + ); + } + + return { + command: "git.connect-account", + result: { workspace, account }, + warnings: [], + nextSteps: [formatCommand(["git", "accounts"])], + }; +} + +export async function runGitInstall( + context: CommandContext, +): Promise> { + const { workspace, api } = await resolveGitAccountContext(context); + + const installUrl = api + ? await createGitHubInstallIntent(api, workspace.id, context.runtime.signal) + : "https://github.com/apps/prisma/installations/new?state=fixture-nonce"; + + return { + command: "git.install", + result: { workspace, installUrl }, + warnings: [], + nextSteps: [], + }; +} + +function gitAccountRequiredError( + connectable: GitConnectableAccountSummary[], + 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 to this workspace." + : "No GitHub account is connectable to this workspace; accounts already installed via other workspaces you belong to would appear here.", + fix: + connectable.length > 0 + ? "Rerun with one of the connectable accounts." + : "Install the Prisma GitHub App first, then connect it.", + exitCode: 2, + meta: { connectable }, + nextSteps: + connectable.length > 0 + ? connectable.map((candidate) => + formatCommand(["git", "connect-account", candidate.accountLogin]), + ) + : [formatCommand(["git", "install"])], + }); +} + +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", "connect-account", candidate.accountLogin]), + ) + : [formatCommand(["git", "install"])], + }); +} + +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", "install"])], + }); + } + return repoConnectionApiError( + "Failed to connect the GitHub account", + response, + error, + ); +} diff --git a/packages/cli/src/lib/github/provider.ts b/packages/cli/src/lib/github/provider.ts deleted file mode 100644 index fed7797..0000000 --- a/packages/cli/src/lib/github/provider.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { ManagementApiClient } from "@prisma/management-api-sdk"; - -import { CliError } from "../../shell/errors"; -import type { - GithubConnectableSummary, - GithubInstallationSummary, -} from "../../types/github"; - -export interface GithubProvider { - listInstallations(workspaceId: string): Promise; - listConnectable(workspaceId: string): Promise; - connect( - workspaceId: string, - installationId: number, - ): Promise; - createInstallIntent(workspaceId: string): Promise; -} - -// The connectable/connect endpoints shipped in the Management API but the -// published @prisma/management-api-sdk does not carry their path types yet. -// This is the single seam that calls them with locally declared shapes; -// delete it and use the typed client once the SDK regen lands. -interface ExperimentalPathsClient { - GET( - path: "/v1/scm-installations/connectable", - init: { - params: { query: { workspaceId: string } }; - signal?: AbortSignal; - }, - ): Promise<{ - data?: { data: GithubConnectableSummary[] }; - response: Response; - }>; - POST( - path: "/v1/scm-installations/connect", - init: { - body: { - provider: "github"; - workspaceId: string; - installationId: number; - }; - signal?: AbortSignal; - }, - ): Promise<{ - data?: { data: RawScmInstallation }; - response: Response; - }>; -} - -interface RawScmInstallation { - installationId: number; - accountLogin: string; - accountType: "user" | "organization"; - suspended: boolean; -} - -function stripWorkspacePrefix(workspaceId: string): string { - return workspaceId.replace(/^ws_/, ""); -} - -export function createGithubProvider( - client: ManagementApiClient, - options: { formatCommand: (args: string[]) => string; signal?: AbortSignal }, -): GithubProvider { - const experimental = client as unknown as ExperimentalPathsClient; - const { formatCommand, signal } = options; - - return { - async listInstallations(workspaceId) { - const result = await client.GET("/v1/scm-installations", { - params: { - query: { workspaceId: stripWorkspacePrefix(workspaceId), limit: 100 }, - }, - signal, - }); - if (!result.data) { - throw githubApiError("list GitHub installations", result.response); - } - return result.data.data.map((record) => ({ - installationId: record.installationId, - accountLogin: record.accountLogin, - accountType: record.accountType, - suspended: record.suspended, - })); - }, - - async listConnectable(workspaceId) { - const result = await experimental.GET( - "/v1/scm-installations/connectable", - { - params: { - query: { workspaceId: stripWorkspacePrefix(workspaceId) }, - }, - signal, - }, - ); - if (!result.data) { - throw githubApiError( - "list connectable GitHub installations", - result.response, - ); - } - return result.data.data.map((record) => ({ - installationId: record.installationId, - accountLogin: record.accountLogin, - })); - }, - - async connect(workspaceId, installationId) { - const result = await experimental.POST("/v1/scm-installations/connect", { - body: { - provider: "github", - workspaceId: stripWorkspacePrefix(workspaceId), - installationId, - }, - signal, - }); - if (!result.data) { - throw githubConnectError(result.response, formatCommand); - } - const record = result.data.data; - return { - installationId: record.installationId, - accountLogin: record.accountLogin, - accountType: record.accountType, - suspended: record.suspended, - }; - }, - - async createInstallIntent(workspaceId) { - const result = await client.POST( - "/v1/scm-installations/install-intents", - { - body: { - provider: "github", - workspaceId: stripWorkspacePrefix(workspaceId), - }, - signal, - }, - ); - if (!result.data) { - throw githubApiError("create a GitHub install intent", result.response); - } - return result.data.data.installUrl; - }, - }; -} - -function githubConnectError( - response: Response, - formatCommand: (args: string[]) => string, -): CliError { - if (response.status === 422) { - return new CliError({ - code: "GITHUB_CONNECT_FAILED", - domain: "github", - 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(["github", "install"])], - }); - } - return githubApiError("connect the GitHub installation", response); -} - -function githubApiError(action: string, response: Response): CliError { - return new CliError({ - code: "GITHUB_API_ERROR", - domain: "github", - summary: `Could not ${action}`, - why: `The Platform API responded with status ${response.status}.`, - fix: "Retry; if the problem persists, check the Console or contact support.", - exitCode: 1, - meta: { status: response.status }, - }); -} diff --git a/packages/cli/src/presenters/github.ts b/packages/cli/src/presenters/github.ts deleted file mode 100644 index 3a83a63..0000000 --- a/packages/cli/src/presenters/github.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { CommandDescriptor } from "../shell/command-meta"; -import { formatDescriptorLabel } from "../shell/command-meta"; -import type { CommandContext } from "../shell/runtime"; -import type { - GithubConnectResult, - GithubInstallResult, - GithubListResult, -} from "../types/github"; - -export function renderGithubList( - context: CommandContext, - descriptor: CommandDescriptor, - result: GithubListResult, -): string[] { - const ui = context.ui; - const rail = ui.dim("│"); - const lines = [ - `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("GitHub installations 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 installation of result.connected) { - const status = installation.suspended ? ui.dim(" (suspended)") : ""; - lines.push( - `${rail} ${ui.accent("connected:")} ${installation.accountLogin} ${ui.dim(`(${installation.accountType}, installation ${installation.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 serializeGithubList(result: GithubListResult) { - return result; -} - -export function renderGithubConnect( - context: CommandContext, - descriptor: CommandDescriptor, - result: GithubConnectResult, -): string[] { - const ui = context.ui; - const rail = ui.dim("│"); - return [ - `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Connecting a GitHub account.")}`, - "", - `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, - `${rail} ${ui.accent("account:")} ${result.installation.accountLogin}`, - `${rail} ${ui.accent("connected:")} yes`, - ]; -} - -export function serializeGithubConnect(result: GithubConnectResult) { - return result; -} - -export function renderGithubInstall( - context: CommandContext, - descriptor: CommandDescriptor, - result: GithubInstallResult, -): string[] { - const ui = context.ui; - const rail = ui.dim("│"); - return [ - `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Install the Prisma GitHub App.")}`, - "", - `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, - `${rail} ${ui.accent("open:")} ${result.installUrl}`, - rail, - `${rail} ${ui.dim("Finish the installation on GitHub; the Console completes the connection.")}`, - ]; -} - -export function serializeGithubInstall(result: GithubInstallResult) { - return result; -} diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index a160ff9..ef67dc3 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -13,6 +13,9 @@ import { renderVerboseBlock, } from "../shell/ui"; import type { + GitAccountsResult, + GitConnectAccountResult, + GitInstallResult, GitRepositoryConnection, ProjectListResult, ProjectRemoveResult, @@ -385,3 +388,85 @@ function formatGitConnectionDetail( return "GitHub repository is connected, but branch automation is not active."; } } + +export function renderGitAccounts( + context: CommandContext, + descriptor: CommandDescriptor, + result: GitAccountsResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${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 serializeGitAccounts(result: GitAccountsResult) { + return result; +} + +export function renderGitConnectAccount( + context: CommandContext, + descriptor: CommandDescriptor, + result: GitConnectAccountResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + return [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Connecting a GitHub account.")}`, + "", + `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, + `${rail} ${ui.accent("account:")} ${result.account.accountLogin}`, + `${rail} ${ui.accent("connected:")} yes`, + ]; +} + +export function serializeGitConnectAccount(result: GitConnectAccountResult) { + return result; +} + +export function renderGitInstall( + context: CommandContext, + descriptor: CommandDescriptor, + result: GitInstallResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + return [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Install the Prisma GitHub App.")}`, + "", + `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, + `${rail} ${ui.accent("open:")} ${result.installUrl}`, + rail, + `${rail} ${ui.dim("Finish the install on GitHub; then connect a repo with git connect.")}`, + ]; +} + +export function serializeGitInstall(result: GitInstallResult) { + return result; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index a0c25b6..574487f 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -188,31 +188,6 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "View your Platform branches", examples: ["prisma-cli branch list"], }, - { - id: "github", - path: ["prisma", "github"], - description: "Manage the workspace's GitHub connection", - examples: ["prisma-cli github list"], - }, - { - id: "github.list", - path: ["prisma", "github", "list"], - description: "List connected and connectable GitHub accounts", - examples: ["prisma-cli github list"], - }, - { - id: "github.connect", - path: ["prisma", "github", "connect"], - description: - "Connect a GitHub account already installed via another workspace", - examples: ["prisma-cli github connect acme-org"], - }, - { - id: "github.install", - path: ["prisma", "github", "install"], - description: "Get the Prisma GitHub App install link", - examples: ["prisma-cli github install"], - }, { id: "build", path: ["prisma", "build"], @@ -322,6 +297,25 @@ const DESCRIPTORS: CommandDescriptor[] = [ "prisma-cli git disconnect --project proj_123", ], }, + { + id: "git.accounts", + path: ["prisma", "git", "accounts"], + description: "List connected and connectable GitHub accounts", + examples: ["prisma-cli git accounts"], + }, + { + id: "git.connect-account", + path: ["prisma", "git", "connect-account"], + description: + "Connect a GitHub account already installed via another workspace", + examples: ["prisma-cli git connect-account acme-org"], + }, + { + id: "git.install", + path: ["prisma", "git", "install"], + description: "Get the Prisma GitHub App install link", + examples: ["prisma-cli git install"], + }, { id: "branch.list", path: ["prisma", "branch", "list"], diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index de7fa6b..4a45beb 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -6,8 +6,7 @@ export type ErrorDomain = | "project" | "branch" | "app" - | "database" - | "github"; + | "database"; export type ErrorSeverity = "error"; export interface CliErrorOptions { diff --git a/packages/cli/src/types/github.ts b/packages/cli/src/types/github.ts deleted file mode 100644 index 2b0020d..0000000 --- a/packages/cli/src/types/github.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AuthWorkspace } from "./auth"; - -export interface GithubInstallationSummary { - /** Numeric GitHub App installation id. */ - installationId: number; - accountLogin: string; - accountType: "user" | "organization"; - suspended: boolean; -} - -export interface GithubConnectableSummary { - /** Numeric GitHub App installation id. */ - installationId: number; - accountLogin: string; -} - -export interface GithubListResult { - workspace: AuthWorkspace; - connected: GithubInstallationSummary[]; - connectable: GithubConnectableSummary[]; -} - -export interface GithubConnectResult { - workspace: AuthWorkspace; - installation: GithubInstallationSummary; -} - -export interface GithubInstallResult { - workspace: AuthWorkspace; - installUrl: string; -} diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index fa424e3..e5d79af 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -134,3 +134,33 @@ 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; +} + +export interface GitInstallResult { + workspace: AuthWorkspace; + installUrl: string; +} diff --git a/packages/cli/tests/github.test.ts b/packages/cli/tests/git-accounts.test.ts similarity index 70% rename from packages/cli/tests/github.test.ts rename to packages/cli/tests/git-accounts.test.ts index d3c123d..c99f002 100644 --- a/packages/cli/tests/github.test.ts +++ b/packages/cli/tests/git-accounts.test.ts @@ -22,13 +22,13 @@ async function login(cwd: string, stateDir: string) { }); } -describe("github commands", () => { +describe("git account commands", () => { it("lists connected and connectable accounts as JSON", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); await login(cwd, stateDir); const result = await executeCli({ - argv: ["github", "list", "--json"], + argv: ["git", "accounts", "--json"], cwd, stateDir, fixturePath, @@ -38,7 +38,7 @@ describe("github commands", () => { expect(result.exitCode).toBe(0); const envelope = JSON.parse(result.stdout); expect(envelope.ok).toBe(true); - expect(envelope.command).toBe("github.list"); + expect(envelope.command).toBe("git.accounts"); expect(envelope.result.connected).toEqual([ { installationId: 555001, @@ -51,17 +51,15 @@ describe("github commands", () => { expect(envelope.result.connectable).toEqual([ { installationId: 555003, accountLogin: "prisma-labs" }, ]); - expect(envelope.nextSteps.join(" ")).toContain( - "github connect prisma-labs", - ); + expect(result.stdout).toContain("git connect-account prisma-labs"); }); - it("connects a connectable account by login and drops it from connectable", async () => { + it("connects a connectable account by login", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); await login(cwd, stateDir); const result = await executeCli({ - argv: ["github", "connect", "prisma-labs", "--json"], + argv: ["git", "connect-account", "prisma-labs", "--json"], cwd, stateDir, fixturePath, @@ -70,9 +68,8 @@ describe("github commands", () => { expect(result.exitCode).toBe(0); const envelope = JSON.parse(result.stdout); - expect(envelope.ok).toBe(true); - expect(envelope.command).toBe("github.connect"); - expect(envelope.result.installation).toEqual({ + expect(envelope.command).toBe("git.connect-account"); + expect(envelope.result.account).toEqual({ installationId: 555003, accountLogin: "prisma-labs", accountType: "organization", @@ -85,7 +82,7 @@ describe("github commands", () => { const stateDir = path.join(cwd, ".state"); await login(cwd, stateDir); const result = await executeCli({ - argv: ["github", "connect", "555003", "--json"], + argv: ["git", "connect-account", "555003", "--json"], cwd, stateDir, fixturePath, @@ -93,17 +90,17 @@ describe("github commands", () => { }); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).result.installation.accountLogin).toBe( + expect(JSON.parse(result.stdout).result.account.accountLogin).toBe( "prisma-labs", ); }); - it("requires an account argument and lists the connectable options", async () => { + it("requires an account and lists the connectable options", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); await login(cwd, stateDir); const result = await executeCli({ - argv: ["github", "connect", "--json"], + argv: ["git", "connect-account", "--json"], cwd, stateDir, fixturePath, @@ -113,11 +110,11 @@ describe("github commands", () => { expect(result.exitCode).toBe(2); const envelope = JSON.parse(result.stdout); expect(envelope.ok).toBe(false); - expect(envelope.error.code).toBe("GITHUB_ACCOUNT_REQUIRED"); + expect(envelope.error.code).toBe("GIT_ACCOUNT_REQUIRED"); expect(envelope.error.meta.connectable).toEqual([ { installationId: 555003, accountLogin: "prisma-labs" }, ]); - expect(result.stdout).toContain("github connect prisma-labs"); + expect(result.stdout).toContain("git connect-account prisma-labs"); }); it("fails with a structured error for an unknown account", async () => { @@ -125,7 +122,7 @@ describe("github commands", () => { const stateDir = path.join(cwd, ".state"); await login(cwd, stateDir); const result = await executeCli({ - argv: ["github", "connect", "nope", "--json"], + argv: ["git", "connect-account", "nope", "--json"], cwd, stateDir, fixturePath, @@ -133,11 +130,7 @@ describe("github commands", () => { }); expect(result.exitCode).toBe(1); - const envelope = JSON.parse(result.stdout); - expect(envelope.error.code).toBe("GITHUB_ACCOUNT_NOT_FOUND"); - expect(envelope.error.meta.connectable).toEqual([ - { installationId: 555003, accountLogin: "prisma-labs" }, - ]); + expect(JSON.parse(result.stdout).error.code).toBe("GIT_ACCOUNT_NOT_FOUND"); }); it("prints the install URL", async () => { @@ -145,7 +138,7 @@ describe("github commands", () => { const stateDir = path.join(cwd, ".state"); await login(cwd, stateDir); const result = await executeCli({ - argv: ["github", "install", "--json"], + argv: ["git", "install", "--json"], cwd, stateDir, fixturePath, @@ -153,7 +146,8 @@ describe("github commands", () => { }); expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.result.installUrl).toContain("github.com/apps/"); + expect(JSON.parse(result.stdout).result.installUrl).toContain( + "github.com/apps/", + ); }); }); From fadbb52de808ed9fd90ec60e82892a7b345b33d0 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 7 Jul 2026 15:38:42 +0530 Subject: [PATCH 3/3] refactor(cli): git account sub-group with interactive install bridge Reworks the GitHub account commands into a git account sub-group: git account list # connected + connectable accounts git account connect [account] # connect one, or install a new one git account connect is the single add-an-account command. With an explicit account it connects directly (agent path). In a terminal with no account it shows a picker of connectable accounts plus a '+ Install a new GitHub account' option; choosing install opens the browser and polls until the account appears, reusing the same open-and-wait mechanism as git connect, so it never dead-ends. In a non-interactive context it returns the connectable options (and install URL when nothing is connectable) as structured data instead of prompting. Replaces the flatter git accounts / connect-account / install trio. --- docs/product/command-spec.md | 52 ++--- docs/product/error-conventions.md | 4 +- packages/cli/src/commands/git/index.ts | 87 ++++----- packages/cli/src/controllers/project.ts | 247 ++++++++++++++++++++---- packages/cli/src/presenters/project.ts | 38 +--- packages/cli/src/shell/command-meta.ts | 29 +-- packages/cli/src/types/project.ts | 2 + packages/cli/tests/git-account.test.ts | 103 ++++++++++ packages/cli/tests/git-accounts.test.ts | 153 --------------- 9 files changed, 397 insertions(+), 318 deletions(-) create mode 100644 packages/cli/tests/git-account.test.ts delete mode 100644 packages/cli/tests/git-accounts.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 0d8cf2a..5b02495 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -894,65 +894,53 @@ prisma-cli git disconnect --project proj_123 prisma-cli git disconnect --json ``` -## `prisma-cli git accounts` +## `prisma-cli git account list` Purpose: -- show the active workspace's GitHub account connections +- show the GitHub accounts connected to the active workspace, and which accounts can be connected without reinstalling Behavior: - requires auth -- lists GitHub accounts connected to the active workspace (login, account type, numeric installation id, suspension state) -- lists accounts connectable without a GitHub round trip: installations of other workspaces the user belongs to, deduplicated per GitHub account with the newest installation winning -- suggests `git connect-account ` as a next step when connectable accounts exist +- 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 accounts -prisma-cli git accounts --json +prisma-cli git account list +prisma-cli git account list --json ``` -## `prisma-cli git connect-account [account]` +## `prisma-cli git account connect [account]` Purpose: -- connect a GitHub account that already has the Prisma GitHub App installed (via another workspace) to the active workspace, without a GitHub round trip +- 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 -- resolves `[account]` by login or numeric installation id among the connectable accounts -- without `[account]`, fails with `GIT_ACCOUNT_REQUIRED`; the connectable accounts are listed in `error.meta.connectable` and as runnable next steps, so agents can pick without prompting +- 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 installation is already actively connected to +- 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 is the account-level connection; link a repository to a project with `git connect` +- this connects the account (organization/user) to the workspace; link a specific repository to a project with `git connect` Examples: ```bash -prisma-cli git connect-account acme-org -prisma-cli git connect-account 555003 -``` - -## `prisma-cli git install` - -Purpose: - -- get the Prisma GitHub App install link for a brand-new GitHub account - -Behavior: - -- requires auth -- prints a single-use, time-limited install URL bound to the active workspace -- the install completes in the browser; then connect a repository with `git connect` +# Interactive: pick a connectable account or install a new one +prisma-cli git account connect -Examples: - -```bash -prisma-cli git install +# Non-interactive: connect a specific account +prisma-cli git account connect acme-org +prisma-cli git account connect 555003 --json ``` ## `prisma-cli branch list` diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 20facd2..dbbb7d4 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -184,6 +184,7 @@ These codes are the minimum stable set for the MVP: - `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` @@ -257,9 +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 connect-account` needs a GitHub account argument; connectable accounts are listed in `error.meta.connectable` and as next steps +- `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/src/commands/git/index.ts b/packages/cli/src/commands/git/index.ts index 5a49437..6de1310 100644 --- a/packages/cli/src/commands/git/index.ts +++ b/packages/cli/src/commands/git/index.ts @@ -1,21 +1,18 @@ import { Command } from "commander"; import { - runGitAccounts, + runGitAccountConnect, + runGitAccountList, runGitConnect, - runGitConnectAccount, runGitDisconnect, - runGitInstall, } from "../../controllers/project"; import { - renderGitAccounts, + renderGitAccountConnect, + renderGitAccountList, renderGitConnect, - renderGitConnectAccount, renderGitDisconnect, - renderGitInstall, - serializeGitAccounts, - serializeGitConnectAccount, - serializeGitInstall, + serializeGitAccountConnect, + serializeGitAccountList, } from "../../presenters/project"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { runCommand } from "../../shell/command-runner"; @@ -27,7 +24,6 @@ import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; import type { GitAccountsResult, GitConnectAccountResult, - GitInstallResult, ProjectRepositoryConnectionResult, } from "../../types/project"; @@ -41,9 +37,7 @@ export function createGitCommand(runtime: CliRuntime): Command { git.addCommand(createGitConnectCommand(runtime)); git.addCommand(createGitDisconnectCommand(runtime)); - git.addCommand(createGitAccountsCommand(runtime)); - git.addCommand(createGitConnectAccountCommand(runtime)); - git.addCommand(createGitInstallCommand(runtime)); + git.addCommand(createGitAccountCommand(runtime)); return git; } @@ -107,10 +101,24 @@ function createGitDisconnectCommand(runtime: CliRuntime): Command { return command; } -function createGitAccountsCommand(runtime: CliRuntime): 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("accounts"), runtime), - "git.accounts", + configureRuntimeCommand(new Command("list"), runtime), + "git.account.list", ); addGlobalFlags(command); @@ -118,13 +126,13 @@ function createGitAccountsCommand(runtime: CliRuntime): Command { command.action(async (options) => { await runCommand( runtime, - "git.accounts", + "git.account.list", options as Record, - (context) => runGitAccounts(context), + (context) => runGitAccountList(context), { renderHuman: (context, descriptor, result) => - renderGitAccounts(context, descriptor, result), - renderJson: (result) => serializeGitAccounts(result), + renderGitAccountList(context, descriptor, result), + renderJson: (result) => serializeGitAccountList(result), }, ); }); @@ -132,10 +140,10 @@ function createGitAccountsCommand(runtime: CliRuntime): Command { return command; } -function createGitConnectAccountCommand(runtime: CliRuntime): Command { +function createGitAccountConnectCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( - configureRuntimeCommand(new Command("connect-account"), runtime), - "git.connect-account", + configureRuntimeCommand(new Command("connect"), runtime), + "git.account.connect", ); command.argument( @@ -147,38 +155,13 @@ function createGitConnectAccountCommand(runtime: CliRuntime): Command { command.action(async (account: string | undefined, options) => { await runCommand( runtime, - "git.connect-account", - options as Record, - (context) => runGitConnectAccount(context, account), - { - renderHuman: (context, descriptor, result) => - renderGitConnectAccount(context, descriptor, result), - renderJson: (result) => serializeGitConnectAccount(result), - }, - ); - }); - - return command; -} - -function createGitInstallCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor( - configureRuntimeCommand(new Command("install"), runtime), - "git.install", - ); - - addGlobalFlags(command); - - command.action(async (options) => { - await runCommand( - runtime, - "git.install", + "git.account.connect", options as Record, - (context) => runGitInstall(context), + (context) => runGitAccountConnect(context, account), { renderHuman: (context, descriptor, result) => - renderGitInstall(context, descriptor, result), - renderJson: (result) => serializeGitInstall(result), + renderGitAccountConnect(context, descriptor, result), + renderJson: (result) => serializeGitAccountConnect(result), }, ); }); diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 80a6d6f..551588a 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -69,6 +69,7 @@ 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"; @@ -2393,7 +2394,7 @@ async function listConnectableGitAccounts( })); } -export async function runGitAccounts( +export async function runGitAccountList( context: CommandContext, ): Promise> { const { workspace, formatCommand, api } = @@ -2412,7 +2413,7 @@ export async function runGitAccounts( ]; return { - command: "git.accounts", + command: "git.account.list", result: { workspace, connected, connectable }, warnings: [], nextSteps: @@ -2420,15 +2421,18 @@ export async function runGitAccounts( ? [ formatCommand([ "git", - "connect-account", + "account", + "connect", connectable[0].accountLogin, ]), ] - : [], + : [formatCommand(["git", "account", "connect"])], }; } -export async function runGitConnectAccount( +const INSTALL_NEW_ACCOUNT = Symbol("install-new-account"); + +export async function runGitAccountConnect( context: CommandContext, accountRef: string | undefined, ): Promise> { @@ -2443,19 +2447,67 @@ export async function runGitConnectAccount( ) : context.api.listConnectableScmInstallations(workspace.id); - if (!accountRef) { - throw gitAccountRequiredError(connectable, formatCommand); + // 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); } - const target = connectable.find( - (candidate) => - candidate.accountLogin === accountRef || - String(candidate.installationId) === accountRef, - ); - if (!target) { - throw gitAccountNotFoundError(accountRef, connectable, formatCommand); + // 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( @@ -2464,7 +2516,7 @@ export async function runGitConnectAccount( body: { provider: "github", workspaceId: workspace.id, - installationId: target.installationId, + installationId, }, signal: context.runtime.signal, }, @@ -2475,37 +2527,132 @@ export async function runGitConnectAccount( account = toGitAccountSummary(data.data); } else { account = toGitAccountSummary( - context.api.connectScmInstallation(workspace.id, target.installationId), + context.api.connectScmInstallation(workspace.id, installationId), ); } return { - command: "git.connect-account", - result: { workspace, account }, + command: "git.account.connect", + result: { workspace, account, newlyInstalled: false }, warnings: [], - nextSteps: [formatCommand(["git", "accounts"])], + nextSteps: [formatCommand(["git", "account", "list"])], }; } -export async function runGitInstall( +async function resolveInstallUrl( context: CommandContext, -): Promise> { - const { workspace, api } = await resolveGitAccountContext(context); + 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); +} - const installUrl = api - ? await createGitHubInstallIntent(api, workspace.id, context.runtime.signal) - : "https://github.com/apps/prisma/installations/new?state=fixture-nonce"; +// 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.install", - result: { workspace, installUrl }, + command: "git.account.connect", + result: { workspace, account, newlyInstalled: true }, warnings: [], - nextSteps: [], + 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({ @@ -2514,20 +2661,25 @@ function gitAccountRequiredError( summary: "GitHub account required", why: connectable.length > 0 - ? "Pass the GitHub account to connect to this workspace." - : "No GitHub account is connectable to this workspace; accounts already installed via other workspaces you belong to would appear here.", + ? "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." - : "Install the Prisma GitHub App first, then connect it.", + : "Open the install URL in a browser to install the app, then rerun.", exitCode: 2, - meta: { connectable }, + meta: { connectable, ...(installUrl ? { installUrl } : {}) }, nextSteps: connectable.length > 0 ? connectable.map((candidate) => - formatCommand(["git", "connect-account", candidate.accountLogin]), + formatCommand([ + "git", + "account", + "connect", + candidate.accountLogin, + ]), ) - : [formatCommand(["git", "install"])], + : [formatCommand(["git", "account", "connect"])], }); } @@ -2550,9 +2702,30 @@ function gitAccountNotFoundError( nextSteps: connectable.length > 0 ? connectable.map((candidate) => - formatCommand(["git", "connect-account", candidate.accountLogin]), + formatCommand([ + "git", + "account", + "connect", + candidate.accountLogin, + ]), ) - : [formatCommand(["git", "install"])], + : [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"])], }); } @@ -2569,7 +2742,7 @@ function gitConnectAccountError( 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", "install"])], + nextSteps: [formatCommand(["git", "account", "connect"])], }); } return repoConnectionApiError( diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index ef67dc3..d98080a 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -15,7 +15,6 @@ import { import type { GitAccountsResult, GitConnectAccountResult, - GitInstallResult, GitRepositoryConnection, ProjectListResult, ProjectRemoveResult, @@ -389,15 +388,15 @@ function formatGitConnectionDetail( } } -export function renderGitAccounts( +export function renderGitAccountList( context: CommandContext, descriptor: CommandDescriptor, result: GitAccountsResult, ): string[] { const ui = context.ui; - const rail = ui.dim("│"); + const rail = ui.dim("\u2502"); const lines = [ - `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("GitHub accounts for the active workspace.")}`, + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("\u2192")} ${ui.dim("GitHub accounts for the active workspace.")}`, "", `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, rail, @@ -426,19 +425,19 @@ export function renderGitAccounts( return lines; } -export function serializeGitAccounts(result: GitAccountsResult) { +export function serializeGitAccountList(result: GitAccountsResult) { return result; } -export function renderGitConnectAccount( +export function renderGitAccountConnect( context: CommandContext, descriptor: CommandDescriptor, result: GitConnectAccountResult, ): string[] { const ui = context.ui; - const rail = ui.dim("│"); + const rail = ui.dim("\u2502"); return [ - `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Connecting a GitHub account.")}`, + `${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}`, @@ -446,27 +445,6 @@ export function renderGitConnectAccount( ]; } -export function serializeGitConnectAccount(result: GitConnectAccountResult) { - return result; -} - -export function renderGitInstall( - context: CommandContext, - descriptor: CommandDescriptor, - result: GitInstallResult, -): string[] { - const ui = context.ui; - const rail = ui.dim("│"); - return [ - `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Install the Prisma GitHub App.")}`, - "", - `${rail} ${ui.accent("workspace:")} ${result.workspace.name}`, - `${rail} ${ui.accent("open:")} ${result.installUrl}`, - rail, - `${rail} ${ui.dim("Finish the install on GitHub; then connect a repo with git connect.")}`, - ]; -} - -export function serializeGitInstall(result: GitInstallResult) { +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 574487f..ece7b32 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -298,23 +298,26 @@ const DESCRIPTORS: CommandDescriptor[] = [ ], }, { - id: "git.accounts", - path: ["prisma", "git", "accounts"], - description: "List connected and connectable GitHub accounts", - examples: ["prisma-cli git accounts"], + id: "git.account", + path: ["prisma", "git", "account"], + description: "Manage the workspace's GitHub account connections", + examples: ["prisma-cli git account list"], }, { - id: "git.connect-account", - path: ["prisma", "git", "connect-account"], - description: - "Connect a GitHub account already installed via another workspace", - examples: ["prisma-cli git connect-account acme-org"], + id: "git.account.list", + path: ["prisma", "git", "account", "list"], + description: "List connected and connectable GitHub accounts", + examples: ["prisma-cli git account list"], }, { - id: "git.install", - path: ["prisma", "git", "install"], - description: "Get the Prisma GitHub App install link", - examples: ["prisma-cli git install"], + 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", diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index e5d79af..674c321 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -158,6 +158,8 @@ export interface GitAccountsResult { 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 { diff --git a/packages/cli/tests/git-account.test.ts b/packages/cli/tests/git-account.test.ts new file mode 100644 index 0000000..a30cbc6 --- /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"); + }); +}); diff --git a/packages/cli/tests/git-accounts.test.ts b/packages/cli/tests/git-accounts.test.ts deleted file mode 100644 index c99f002..0000000 --- a/packages/cli/tests/git-accounts.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -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, - }); -} - -describe("git account commands", () => { - it("lists connected and connectable accounts as JSON", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await login(cwd, stateDir); - const result = await executeCli({ - argv: ["git", "accounts", "--json"], - cwd, - stateDir, - fixturePath, - isTTY: false, - }); - - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.ok).toBe(true); - expect(envelope.command).toBe("git.accounts"); - expect(envelope.result.connected).toEqual([ - { - installationId: 555001, - accountLogin: "acme-bot", - accountType: "organization", - suspended: false, - }, - ]); - // Two fixture rows share the prisma-labs account; the newest wins. - expect(envelope.result.connectable).toEqual([ - { installationId: 555003, accountLogin: "prisma-labs" }, - ]); - expect(result.stdout).toContain("git connect-account prisma-labs"); - }); - - it("connects a connectable account by login", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await login(cwd, stateDir); - const result = await executeCli({ - argv: ["git", "connect-account", "prisma-labs", "--json"], - cwd, - stateDir, - fixturePath, - isTTY: false, - }); - - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.command).toBe("git.connect-account"); - expect(envelope.result.account).toEqual({ - installationId: 555003, - accountLogin: "prisma-labs", - accountType: "organization", - suspended: false, - }); - }); - - it("connects by numeric installation id", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await login(cwd, stateDir); - const result = await executeCli({ - argv: ["git", "connect-account", "555003", "--json"], - cwd, - stateDir, - fixturePath, - isTTY: false, - }); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).result.account.accountLogin).toBe( - "prisma-labs", - ); - }); - - it("requires an account and lists the connectable options", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await login(cwd, stateDir); - const result = await executeCli({ - argv: ["git", "connect-account", "--json"], - cwd, - stateDir, - fixturePath, - isTTY: false, - }); - - 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 connect-account prisma-labs"); - }); - - it("fails with a structured error for an unknown account", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await login(cwd, stateDir); - const result = await executeCli({ - argv: ["git", "connect-account", "nope", "--json"], - cwd, - stateDir, - fixturePath, - isTTY: false, - }); - - expect(result.exitCode).toBe(1); - expect(JSON.parse(result.stdout).error.code).toBe("GIT_ACCOUNT_NOT_FOUND"); - }); - - it("prints the install URL", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await login(cwd, stateDir); - const result = await executeCli({ - argv: ["git", "install", "--json"], - cwd, - stateDir, - fixturePath, - isTTY: false, - }); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout).result.installUrl).toContain( - "github.com/apps/", - ); - }); -});