From e98cc13ae8e7c3eaabdc467316750927216edb00 Mon Sep 17 00:00:00 2001 From: Philip Olson Date: Mon, 10 Aug 2026 07:15:20 -0700 Subject: [PATCH] feat(cli): add --agent mode to checkout and needs_branch to link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let an agent complete the link → checkout flow in structured JSON. link --agent now returns needs_branch instead of a false terminal "linked" when no branch is pinned, and checkout --agent pins a branch, pulls env, and reports checked_out with an env_pull status. Includes tests. Co-authored-by: Isaac --- packages/cli/e2e/checkout.e2e.test.ts | 125 ++++++++++++ .../projects/proj-in-org/branches/GET.json | 13 ++ .../__snapshots__/checkout.test.ts.snap | 155 ++++++++++++++ .../commands/__snapshots__/link.test.ts.snap | 65 ++++-- packages/cli/src/commands/checkout.test.ts | 123 ++++++++++- packages/cli/src/commands/checkout.ts | 191 +++++++++++++++++- packages/cli/src/commands/link.test.ts | 4 +- packages/cli/src/commands/link.ts | 29 ++- packages/cli/src/types.ts | 6 + 9 files changed, 685 insertions(+), 26 deletions(-) create mode 100644 packages/cli/e2e/checkout.e2e.test.ts create mode 100644 packages/cli/mocks/main/projects/proj-in-org/branches/GET.json diff --git a/packages/cli/e2e/checkout.e2e.test.ts b/packages/cli/e2e/checkout.e2e.test.ts new file mode 100644 index 00000000..b757b1ef --- /dev/null +++ b/packages/cli/e2e/checkout.e2e.test.ts @@ -0,0 +1,125 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + createProject, + deleteProject, + runCli, + uniqueProjectName, +} from "./helpers.js"; + +/** + * `neon checkout --agent` pins a branch and then pulls its env. The unit suites cover the + * response shapes against mocks, but with `--no-env-pull` — so the one path they cannot reach + * is the one that matters most: a real pull that resolves a connection string and lands it on + * disk, and the `checked_out`/`written` JSON the command assembles from that outcome. + * + * This is the regression that guards it. It is the analogue of the `pull_env` step in + * `init.e2e.test.ts`, scoped to the `checkout --agent` response contract: the field the agent + * reads (`env_pull`, `env_file`, `pulled`) and the file those fields describe must agree. + */ +describe.sequential("e2e — neon checkout --agent pulls env for real", () => { + let projectId: string; + + /** + * `env pull` writes a connection string and password here, so the whole directory is + * throwaway and removed in teardown, credentials included. + */ + const workdir = mkdtempSync(join(tmpdir(), "neon-checkout-e2e-")); + const contextFile = join(workdir, ".neon"); + + beforeAll(async () => { + projectId = await createProject({ name: uniqueProjectName("cli-co") }); + }); + + afterAll(async () => { + rmSync(workdir, { recursive: true, force: true }); + if (projectId) await deleteProject(projectId); + }); + + it("pins the branch and reports the connection string it wrote to disk", async () => { + const result = await runCli( + ["checkout", "main", "--agent", "--project-id", projectId], + { cwd: workdir, contextFile }, + ); + expect(result.code, result.stderr).toBe(0); + + const response = JSON.parse(result.stdout) as { + status: string; + env_pull: string; + env_file?: string; + pulled?: string[]; + context: { projectId: string; branch: string }; + }; + + // The branch is pinned and env actually landed: `written`, not `empty`/`skipped`. + expect(response.status).toBe("checked_out"); + expect(response.env_pull).toBe("written"); + expect(response.context.projectId).toBe(projectId); + expect(response.context.branch).toBe("main"); + + // The fields the agent reads must name what was written. + expect(response.env_file).toMatch(/\.env\.local$/); + expect(response.pulled).toContain("DATABASE_URL"); + + // Exit 0 and a `written` status are not enough on their own — the whole point is a + // connection string on disk, so read the file the response points at and prove it. + const envFile = join(workdir, ".env.local"); + expect(existsSync(envFile)).toBe(true); + expect(readFileSync(envFile, "utf8")).toMatch( + /^DATABASE_URL="?postgresql:\/\/.+/m, + ); + }); + + it("still reports checked_out when the env pull fails — the pin stands", async () => { + // The design point that motivated this whole change: pinning a branch and + // pulling its env are separate outcomes. A failed pull must NOT be reported + // as a top-level failure, because the branch really is pinned — the agent + // just has to notice `env_pull: "failed"` and re-run `env pull`. + // + // Force the pull to fail deterministically without depending on API flakiness: + // pre-create `.env.local` as a *directory*, so the pull throws EISDIR reading + // it back — after the pin has already written `.neon`. + const failWorkdir = mkdtempSync( + join(tmpdir(), "neon-checkout-e2e-fail-"), + ); + mkdirSync(join(failWorkdir, ".env.local")); + const failContext = join(failWorkdir, ".neon"); + + const result = await runCli( + ["checkout", "main", "--agent", "--project-id", projectId], + { cwd: failWorkdir, contextFile: failContext }, + ); + + try { + // Exit 0: a failed pull is a soft failure. The pin succeeded, so the + // command does not fail the process — the outcome is carried in the JSON + // (`env_pull: "failed"`), which is the field an agent must branch on. + expect(result.code, result.stderr).toBe(0); + const response = JSON.parse(result.stdout) as { + status: string; + env_pull: string; + context: { projectId: string; branch: string }; + }; + // The branch was still pinned: checked_out, with env_pull flagging that + // DATABASE_URL never landed on disk. + expect(response.status).toBe("checked_out"); + expect(response.env_pull).toBe("failed"); + expect(response.context.projectId).toBe(projectId); + expect(response.context.branch).toBe("main"); + // The pin is the durable half — it's in `.neon` regardless of the pull. + expect(JSON.parse(readFileSync(failContext, "utf8")).branch).toBe( + "main", + ); + } finally { + rmSync(failWorkdir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/mocks/main/projects/proj-in-org/branches/GET.json b/packages/cli/mocks/main/projects/proj-in-org/branches/GET.json new file mode 100644 index 00000000..566f0467 --- /dev/null +++ b/packages/cli/mocks/main/projects/proj-in-org/branches/GET.json @@ -0,0 +1,13 @@ +{ + "branches": [ + { + "id": "br-in-org-branch-123456", + "project_id": "proj-in-org", + "name": "main", + "default": true, + "current_state": "ready", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ] +} diff --git a/packages/cli/src/commands/__snapshots__/checkout.test.ts.snap b/packages/cli/src/commands/__snapshots__/checkout.test.ts.snap index cac04973..7e0cb666 100644 --- a/packages/cli/src/commands/__snapshots__/checkout.test.ts.snap +++ b/packages/cli/src/commands/__snapshots__/checkout.test.ts.snap @@ -1,5 +1,160 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`checkout > --agent mode > --agent emits parseable JSON on stdout with the invoked binary name in the template 1`] = ` +"{ + "status": "needs_branch", + "instruction": "Ask the user which branch to check out, then re-run the next_command_template with the chosen branch name.", + "options": [ + { + "id": "br-main-branch-123456", + "name": "main", + "default": true + }, + { + "id": "br-sunny-branch-123456", + "name": "test_branch", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "123", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_fixed_cu", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_autoscaling", + "default": false + }, + { + "id": "br-protected-branch-123456", + "name": "protected_branch", + "default": false + } + ], + "context": { + "projectId": "test" + }, + "next_command_template": "neon checkout --agent --project-id test" +} +" +`; + +exports[`checkout > --agent mode > a branch name pins it and emits checked_out JSON 1`] = ` +"{ + "status": "checked_out", + "context_file": "/agent_checked_out/.neon", + "context": { + "projectId": "test", + "branch": "main" + }, + "env_pull": "skipped", + "message": "Checked out main. Env pull was skipped (--no-env-pull); no vars written." +} +" +`; + +exports[`checkout > --agent mode > a nonexistent branch emits needs_branch (never silently creates) 1`] = ` +"{ + "status": "needs_branch", + "instruction": "Branch \\"no-such-branch\\" was not found in this project. Ask the user which existing branch to check out, then re-run the next_command_template with it. This never creates a branch.", + "options": [ + { + "id": "br-main-branch-123456", + "name": "main", + "default": true + }, + { + "id": "br-sunny-branch-123456", + "name": "test_branch", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "123", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_fixed_cu", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_autoscaling", + "default": false + }, + { + "id": "br-protected-branch-123456", + "name": "protected_branch", + "default": false + } + ], + "context": { + "projectId": "test" + }, + "next_command_template": "neon checkout --agent --project-id test" +} +" +`; + +exports[`checkout > --agent mode > no branch arg on a multi-branch project emits needs_branch JSON 1`] = ` +"{ + "status": "needs_branch", + "instruction": "Ask the user which branch to check out, then re-run the next_command_template with the chosen branch name.", + "options": [ + { + "id": "br-main-branch-123456", + "name": "main", + "default": true + }, + { + "id": "br-sunny-branch-123456", + "name": "test_branch", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "123", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_fixed_cu", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_autoscaling", + "default": false + }, + { + "id": "br-protected-branch-123456", + "name": "protected_branch", + "default": false + } + ], + "context": { + "projectId": "test" + }, + "next_command_template": "neon checkout --agent --project-id test" +} +" +`; + +exports[`checkout > --agent mode > with no resolvable project emits an error JSON, never a prompt 1`] = ` +"{ + "status": "error", + "code": "INTERNAL_ERROR", + "message": "Could not determine which Neon project to check out a branch from. Provide one via the --project-id flag or a .neon file (created by \`neon link\` / \`neon set-context\`)." +} +" +`; + exports[`checkout > announces the branch currently pinned before switching to a new one 1`] = `""`; exports[`checkout > auto-detects the project when the API key maps to a single project 1`] = `""`; diff --git a/packages/cli/src/commands/__snapshots__/link.test.ts.snap b/packages/cli/src/commands/__snapshots__/link.test.ts.snap index bf9000e8..335d266e 100644 --- a/packages/cli/src/commands/__snapshots__/link.test.ts.snap +++ b/packages/cli/src/commands/__snapshots__/link.test.ts.snap @@ -76,46 +76,79 @@ exports[`link > --agent mode > with only --org-id emits needs_project JSON 1`] = " `; -exports[`link > --agent mode > with only --project-id infers the org and emits linked JSON 1`] = ` +exports[`link > --agent mode > with only --project-id infers the org and emits needs_branch JSON 1`] = ` "{ - "status": "linked", - "context_file": "/agent_linked_infer/.neon", + "status": "needs_branch", + "instruction": "Linked, but no branch is pinned and no env is written yet. Ask the user which branch to check out (or accept the default), then run the next_command_template to pin it and pull its env vars.", + "options": [ + { + "id": "br-in-org-branch-123456", + "name": "main", + "default": true + } + ], "context": { "orgId": "org-7", "projectId": "proj-in-org" }, - "project": { - "id": "proj-in-org" - }, - "message": "Linked /agent_linked_infer/.neon to project proj-in-org (org org-7). No branch pinned — run \`neon checkout \` (omit the branch to list options) to pin one and pull its env vars." + "next_command_template": "neon checkout --agent --project-id proj-in-org" } " `; -exports[`link > --agent mode > with only --project-id infers the org and emits linked JSON 2`] = ` +exports[`link > --agent mode > with only --project-id infers the org and emits needs_branch JSON 2`] = ` "{ "orgId": "org-7", "projectId": "proj-in-org" }" `; -exports[`link > --agent mode > with org+project emits linked JSON (no branch) and writes .neon 1`] = ` +exports[`link > --agent mode > with org+project (no branch) emits needs_branch JSON and writes .neon 1`] = ` "{ - "status": "linked", - "context_file": "/agent_linked_existing/.neon", + "status": "needs_branch", + "instruction": "Linked, but no branch is pinned and no env is written yet. Ask the user which branch to check out (or accept the default), then run the next_command_template to pin it and pull its env vars.", + "options": [ + { + "id": "br-main-branch-123456", + "name": "main", + "default": true + }, + { + "id": "br-sunny-branch-123456", + "name": "test_branch", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "123", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_fixed_cu", + "default": false + }, + { + "id": "br-numbered-branch-123456", + "name": "test_branch_with_autoscaling", + "default": false + }, + { + "id": "br-protected-branch-123456", + "name": "protected_branch", + "default": false + } + ], "context": { "orgId": "org-2", "projectId": "test" }, - "project": { - "id": "test" - }, - "message": "Linked /agent_linked_existing/.neon to project test (org org-2). No branch pinned — run \`neon checkout \` (omit the branch to list options) to pin one and pull its env vars." + "next_command_template": "neon checkout --agent --project-id test" } " `; -exports[`link > --agent mode > with org+project emits linked JSON (no branch) and writes .neon 2`] = ` +exports[`link > --agent mode > with org+project (no branch) emits needs_branch JSON and writes .neon 2`] = ` "{ "orgId": "org-2", "projectId": "test" diff --git a/packages/cli/src/commands/checkout.test.ts b/packages/cli/src/commands/checkout.test.ts index 1450a5ac..e3706856 100644 --- a/packages/cli/src/commands/checkout.test.ts +++ b/packages/cli/src/commands/checkout.test.ts @@ -7,7 +7,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect } from "vitest"; +import { beforeAll, describe, expect } from "vitest"; import { test as originalTest } from "../test_utils/fixtures"; import { ENV_PULL_SKIPPED_HINT } from "./env"; @@ -17,6 +17,25 @@ import { ENV_PULL_SKIPPED_HINT } from "./env"; // stable across runs and machines. const TEST_TMP = mkdtempSync(join(tmpdir(), "neonctl-checkout-")); +const TMP_TOKEN = ""; + +beforeAll(() => { + // Replace the per-run tmp directory with a stable token so snapshots (e.g. the + // `checked_out` agent JSON, which carries the absolute context-file path) only + // keep the deterministic suffix. + expect.addSnapshotSerializer({ + test: (val) => typeof val === "string" && val.includes(TEST_TMP), + serialize: (val, config, indentation, depth, refs, printer) => + printer( + (val as string).split(TEST_TMP).join(TMP_TOKEN), + config, + indentation, + depth, + refs, + ), + }); +}); + const test = originalTest.extend<{ readFile: (name: string) => string; removeFile: (name: string) => void; @@ -284,4 +303,106 @@ describe("checkout", () => { ); removeFile(ctx); }); + + describe("--agent mode", () => { + test("no branch arg on a multi-branch project emits needs_branch JSON", async ({ + testCliCommand, + removeFile, + tmpContext, + }) => { + const ctx = tmpContext("agent_needs_branch"); + await testCliCommand([ + "checkout", + "--agent", + "--project-id", + "test", + "--context-file", + ctx, + ]); + removeFile(ctx); + }); + + test("a branch name pins it and emits checked_out JSON", async ({ + testCliCommand, + readFile, + removeFile, + tmpContext, + }) => { + // --no-env-pull keeps the response deterministic against the mock; the + // env-pull path is covered by the live e2e. The context_file path in the + // JSON is normalized to by the snapshot serializer above. + const ctx = tmpContext("agent_checked_out"); + await testCliCommand([ + "checkout", + "main", + "--agent", + "--no-env-pull", + "--project-id", + "test", + "--context-file", + ctx, + ]); + expect(parseContext(readFile(ctx))).toEqual({ + projectId: "test", + branch: "main", + }); + removeFile(ctx); + }); + + test("a nonexistent branch emits needs_branch (never silently creates)", async ({ + testCliCommand, + removeFile, + tmpContext, + }) => { + const ctx = tmpContext("agent_not_found"); + await testCliCommand([ + "checkout", + "no-such-branch", + "--agent", + "--project-id", + "test", + "--context-file", + ctx, + ]); + removeFile(ctx); + }); + + test("with no resolvable project emits an error JSON, never a prompt", async ({ + testCliCommand, + removeFile, + tmpContext, + }) => { + // Fresh .neon, no --project-id, and the mock account has no projects to + // auto-detect. The human path offers an interactive `link` here; agent + // mode must never prompt, so it has to fall through to an error JSON on + // stdout (exit 1) that the caller can parse. + const ctx = tmpContext("agent_no_project"); + await testCliCommand( + ["checkout", "main", "--agent", "--context-file", ctx], + { mockDir: "checkout_no_project", code: 1 }, + ); + removeFile(ctx); + }); + + test("--agent emits parseable JSON on stdout with the invoked binary name in the template", async ({ + testCliCommand, + removeFile, + tmpContext, + }) => { + // A regression guard: the agent output must be valid JSON (not prose) + // and its next_command_template must name `neon`, never the removed + // `neonctl` binary. testCliCommand snapshots stdout; the assertions in + // the other cases pin the shapes. + const ctx = tmpContext("agent_json_shape"); + await testCliCommand([ + "checkout", + "--agent", + "--project-id", + "test", + "--context-file", + ctx, + ]); + removeFile(ctx); + }); + }); }); diff --git a/packages/cli/src/commands/checkout.ts b/packages/cli/src/commands/checkout.ts index 11422077..d8d815eb 100644 --- a/packages/cli/src/commands/checkout.ts +++ b/packages/cli/src/commands/checkout.ts @@ -7,7 +7,7 @@ import { isNeonApiError } from "../api.js"; import { applyContext, contextBranch, readContextFile } from "../context.js"; import { isCi } from "../env.js"; import { log } from "../log.js"; -import type { CommonProps } from "../types.js"; +import type { AgentBranchOption, CommonProps } from "../types.js"; import { createBranch, pickBranchInteractively, @@ -27,6 +27,8 @@ type CheckoutProps = CommonProps & { orgId?: string; id?: string; envPull: boolean; + /** Emit a JSON state-machine response for agents instead of prompting. */ + agent?: boolean; /** Global `--color` flag (default true); `--no-color` sets it false to force plain output. */ color?: boolean; }; @@ -58,6 +60,15 @@ export const builder = (argv: yargs.Argv) => type: "boolean", default: true, }, + agent: { + describe: + "Emit a JSON state-machine response designed for AI agents instead of " + + "prompting. With no branch, returns a `needs_branch` response listing the " + + "branches to choose from; with a branch, pins it, pulls env, and returns " + + "`checked_out`.", + type: "boolean", + default: false, + }, }) .example([ [ @@ -75,6 +86,14 @@ export const builder = (argv: yargs.Argv) => ]); export const handler = async (props: CheckoutProps) => { + // Agent mode: emit a JSON state-machine response instead of the human flow + // (prompts/log lines). Kept as an early return so the interactive path below + // is untouched. + if (props.agent) { + await runCheckoutAgent(props); + return; + } + // Show where the context is pinned *before* we switch it, so the user sees the move // ("currently on X" → "checked out Y") and can catch a checkout they didn't mean to make. // Read straight from `.neon` (a name, no API call); silent when nothing is pinned yet. @@ -159,6 +178,171 @@ export const handler = async (props: CheckoutProps) => { } }; +// ---------------------------------------------------------------------------- +// Agent mode (JSON state machine) — mirrors `link --agent`'s contract so an +// agent walking the link → checkout flow sees one consistent shape. +// ---------------------------------------------------------------------------- + +type CheckoutAgentResponse = + | { + status: "needs_branch"; + instruction: string; + options: AgentBranchOption[]; + // Mirrors `link --agent`'s needs_branch shape so an agent sees one contract. + context: { orgId?: string; projectId: string }; + next_command_template: string; + } + | { + status: "checked_out"; + context_file: string; + context: { orgId?: string; projectId: string; branch: string }; + // The branch is pinned; `env_pull` reports whether env actually landed on + // disk. A "failed"/"empty" pull is NOT a clean success — the branch is + // checked out but DATABASE_URL may be absent, so an agent must check this. + env_pull: "written" | "empty" | "skipped" | "failed"; + env_file?: string; + pulled?: string[]; + message: string; + } + | { status: "error"; code: string; message: string }; + +const emitCheckoutAgent = (response: CheckoutAgentResponse) => { + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); +}; + +// Quote a value for a copy-pasteable `next_command_template`, matching how +// `link` and `bootstrap` build theirs. Project ids are already shell-safe, but +// keeping the same helper keeps the three commands' templates consistent. +const shellArg = (value: string): string => { + if (/^[A-Za-z0-9._:/-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +}; + +/** + * Agent-mode checkout. Resolves the project, then: + * - no branch arg + >1 branch → `needs_branch` (list + next_command_template) + * - no branch arg + 1 branch → pin it, pull env, `checked_out` + * - branch arg that exists → pin it, pull env, `checked_out` + * - branch arg not found → `needs_branch` (never silently create; branch + * creation is stateful and needs explicit confirmation) + * Never prompts. Errors are emitted as `{ status: "error" }` with exit 1. + */ +const runCheckoutAgent = async (props: CheckoutProps): Promise => { + try { + const projectId = await resolveProjectId(props); + const branches = ( + await props.apiClient.listProjectBranches({ projectId }) + ).data.branches; + + // A project always has a default branch; an empty list means a transient + // API issue, so surface it as an error rather than an empty needs_branch. + if (branches.length === 0) { + emitCheckoutAgent({ + status: "error", + code: "NO_BRANCHES", + message: `Project ${projectId} returned no branches.`, + }); + process.exitCode = 1; + return; + } + + const options: AgentBranchOption[] = branches.map((b: Branch) => ({ + id: b.id, + name: b.name ?? b.id, + default: Boolean(b.default), + })); + + // The next command an agent runs (checkout again with a branch). Thread the + // ids we already hold so it's self-contained; --org-id only when it was + // passed (checkout resolves the org from --project-id, so it's optional). + const nextTemplate = + `${getCliName()} checkout --agent --project-id ${shellArg(projectId)}` + + (props.orgId ? ` --org-id ${shellArg(props.orgId)}` : ""); + + // Resolve which branch to pin, or emit needs_branch and stop. + let target: Branch | undefined; + if (props.id) { + const ref = props.id; + target = looksLikeBranchId(ref) + ? branches.find((b: Branch) => b.id === ref) + : branches.find((b: Branch) => b.name === ref); + if (!target) { + emitCheckoutAgent({ + status: "needs_branch", + instruction: `Branch "${ref}" was not found in this project. Ask the user which existing branch to check out, then re-run the next_command_template with it. This never creates a branch.`, + options, + context: { projectId }, + next_command_template: nextTemplate, + }); + return; + } + } else if (branches.length === 1) { + target = branches[0]; + } else { + emitCheckoutAgent({ + status: "needs_branch", + instruction: + "Ask the user which branch to check out, then re-run the next_command_template with the chosen branch name.", + options, + context: { projectId }, + next_command_template: nextTemplate, + }); + return; + } + + // `target` is set on every path that reaches here (the branches-empty and + // not-found/needs-branch cases returned above); this narrows the type. + if (!target) return; + + const branchName = target.name ?? target.id; + const orgId = await resolveOrgId(props, projectId); + + applyContext(props.contextFile, { + projectId, + ...(orgId ? { orgId } : {}), + branch: branchName, + }); + + const pull = await autoPullEnvAfterPin({ + ...props, + projectId, + branch: target.id, + envPull: props.envPull, + }); + + emitCheckoutAgent({ + status: "checked_out", + context_file: props.contextFile, + context: { + ...(orgId ? { orgId } : {}), + projectId, + branch: branchName, + }, + env_pull: pull.status, + ...(pull.status === "written" + ? { env_file: pull.file, pulled: pull.written } + : {}), + message: + pull.status === "written" + ? `Checked out ${branchName} and pulled ${pull.written.length} Neon env var${pull.written.length === 1 ? "" : "s"} into ${pull.file}.` + : pull.status === "failed" + ? `Checked out ${branchName}, but pulling env vars failed (${pull.message}). DATABASE_URL is not on disk; resolve the cause and run \`${getCliName()} env pull\`.` + : pull.status === "skipped" + ? `Checked out ${branchName}. Env pull was skipped (--no-env-pull); no vars written.` + : `Checked out ${branchName}. No Neon env vars to pull for this branch yet.`, + }); + } catch (err) { + emitCheckoutAgent({ + status: "error", + code: isNeonApiError(err) ? "API_ERROR" : "INTERNAL_ERROR", + message: err instanceof Error ? err.message : String(err), + }); + process.exitCode = 1; + } +}; + /** * Apply the policy to a branch `checkout` just created bare, returning the failure message * instead of throwing it. The branch and the context pin already stand at this point, so a @@ -390,7 +574,10 @@ const resolveProjectId = async (props: CheckoutProps): Promise => { "Provide one via the --project-id flag " + `or a .neon file (created by \`${getCliName()} link\` / \`${getCliName()} set-context\`).`; - if (isCi() || !process.stdout.isTTY) { + // Agent mode must never prompt: the caller reads JSON off stdout, so throw + // (the runCheckoutAgent catch turns it into a `{ status: "error" }` response) + // rather than blocking on a confirm it can't answer. + if (props.agent || isCi() || !process.stdout.isTTY) { throw new Error(missingProjectMessage); } diff --git a/packages/cli/src/commands/link.test.ts b/packages/cli/src/commands/link.test.ts index 1a401cbb..13f66c2b 100644 --- a/packages/cli/src/commands/link.test.ts +++ b/packages/cli/src/commands/link.test.ts @@ -423,7 +423,7 @@ describe("link", () => { removeFile(ctx); }); - test("with org+project emits linked JSON (no branch) and writes .neon", async ({ + test("with org+project (no branch) emits needs_branch JSON and writes .neon", async ({ testCliCommand, readFile, tmpContext, @@ -443,7 +443,7 @@ describe("link", () => { expect(readFile(ctx)).toMatchSnapshot(); }); - test("with only --project-id infers the org and emits linked JSON", async ({ + test("with only --project-id infers the org and emits needs_branch JSON", async ({ testCliCommand, readFile, tmpContext, diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts index 575e9afb..99513f29 100644 --- a/packages/cli/src/commands/link.ts +++ b/packages/cli/src/commands/link.ts @@ -19,7 +19,7 @@ import { } from "../context.js"; import { isCi } from "../env.js"; import { log } from "../log.js"; -import type { CommonProps } from "../types.js"; +import type { AgentBranchOption, CommonProps } from "../types.js"; import { createBranch, pickBranchInteractively, @@ -85,6 +85,13 @@ type AgentResponse = regions: AgentRegionOption[]; next_command_template: string; } + | { + status: "needs_branch"; + instruction: string; + options: AgentBranchOption[]; + context: AgentContext; + next_command_template: string; + } | { status: "linked"; context_file: string; @@ -956,12 +963,24 @@ const runAgent = async (props: LinkProps, inputs: Inputs) => { }); return; } + // Linked, but no branch pinned yet — this is NOT the terminal state: there + // is no branch and no env. Return `needs_branch` (mirroring needs_org / + // needs_project) so an agent continues into `checkout --agent` instead of + // stopping at a false "linked". + const { data: branchData } = await props.apiClient.listProjectBranches({ + projectId, + }); emitAgent({ - status: "linked", - context_file: props.contextFile, + status: "needs_branch", + instruction: + "Linked, but no branch is pinned and no env is written yet. Ask the user which branch to check out (or accept the default), then run the next_command_template to pin it and pull its env vars.", + options: branchData.branches.map((b) => ({ + id: b.id, + name: b.name ?? b.id, + default: Boolean(b.default), + })), context: { orgId, projectId }, - project: { id: projectId }, - message: `Linked ${props.contextFile} to project ${projectId}${orgSuffix}. No branch pinned — run \`${getCliName()} checkout \` (omit the branch to list options) to pin one and pull its env vars.`, + next_command_template: `${getCliName()} checkout --agent --project-id ${shellArg(projectId)}`, }); return; } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index f1a8a910..5d3cc160 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -32,3 +32,9 @@ export type BranchScopeProps = ProjectScopeProps & export type ExtendedTokenSet = TokenEndpointResponse & { expires_at: number; }; + +/** + * A branch option in an `--agent` `needs_branch` response. Emitted by both `link` + * and `checkout`, so it lives here to keep the two commands' contract identical. + */ +export type AgentBranchOption = { id: string; name: string; default: boolean };