Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <account>` 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:
Expand Down
8 changes: 8 additions & 0 deletions docs/product/error-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions packages/cli/fixtures/mock-api.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
{
"providers": [
{ "id": "github", "name": "GitHub" },
{ "id": "google", "name": "Google" }
{
"id": "github",
"name": "GitHub"
},
{
"id": "google",
"name": "Google"
}
],
"users": [
{
Expand Down Expand Up @@ -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"
}
]
}
74 changes: 74 additions & 0 deletions packages/cli/src/adapters/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +103,7 @@ interface MockApiData {
users: UserRecord[];
workspaces: WorkspaceRecord[];
memberships: MembershipRecord[];
scmInstallations?: ScmInstallationRecord[];
projects: ProjectRecord[];
branches: BranchRecord[];
deployments: DeploymentRecord[];
Expand Down Expand Up @@ -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<number>();
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,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
121 changes: 121 additions & 0 deletions packages/cli/src/commands/github/index.ts
Original file line number Diff line number Diff line change
@@ -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<GithubListResult>(
runtime,
"github.list",
options as Record<string, unknown>,
(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<GithubConnectResult>(
runtime,
"github.connect",
options as Record<string, unknown>,
(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<GithubInstallResult>(
runtime,
"github.install",
options as Record<string, unknown>,
(context) => runGithubInstall(context),
{
renderHuman: (context, descriptor, result) =>
renderGithubInstall(context, descriptor, result),
renderJson: (result) => serializeGithubInstall(result),
},
);
});

return command;
}
Loading
Loading