diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 4413a6cf..8c1d0713 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -77,6 +77,9 @@ describe("init", () => { agent: "cursor", skipMigrations: undefined, preview: undefined, + projectId: undefined, + orgId: undefined, + branchId: undefined, }); expect(interactiveInit).not.toHaveBeenCalled(); }); @@ -96,6 +99,9 @@ describe("init", () => { agent: "claude", skipMigrations: true, preview: undefined, + projectId: undefined, + orgId: undefined, + branchId: undefined, }); }); @@ -105,7 +111,12 @@ describe("init", () => { await handler({ preview: true }); - expect(interactiveInit).toHaveBeenCalledWith({ preview: true }); + expect(interactiveInit).toHaveBeenCalledWith({ + preview: true, + projectId: undefined, + orgId: undefined, + branchId: undefined, + }); }); test("should pass preview to orchestrate in agent mode", async () => { @@ -120,6 +131,9 @@ describe("init", () => { agent: "cursor", skipMigrations: undefined, preview: true, + projectId: undefined, + orgId: undefined, + branchId: undefined, }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a2c4129c..c4fb45f6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,6 +3,7 @@ import type yargs from "yargs"; import { closeAnalytics, sendError } from "../analytics.js"; import { detectAgent } from "../init/detect_agent.js"; import { enrichResponse } from "../init/enrich_output.js"; +import { assertSafeId } from "../init/ids.js"; import { interactiveInit } from "../init/interactive.js"; import { orchestrate } from "../init/orchestrate.js"; import { routeDataStep } from "../init/route_command.js"; @@ -38,6 +39,21 @@ export const builder = (yargs: yargs.Argv) => describe: "Enable preview features (e.g. project bootstrapping from templates).", }) + .option("project-id", { + type: "string", + describe: + "Use an existing Neon project by ID. Skips organization and project selection.", + }) + .option("org-id", { + type: "string", + describe: + "Scope setup to an existing organization by ID. Skips organization selection.", + }) + .option("branch-id", { + type: "string", + describe: + "Target a specific branch by ID when pulling environment variables.", + }) .strict(false); /** @@ -77,6 +93,9 @@ export const handler = async (argv: { data?: string; skipMigrations?: boolean; preview?: boolean; + projectId?: string; + orgId?: string; + branchId?: string; profile?: string; }) => { // Auto-detect agent from environment. When --agent is explicitly passed, @@ -115,6 +134,13 @@ export const handler = async (argv: { ); } + // Validate IDs up front — they are interpolated into shell commands + // downstream, and an early, well-shaped rejection beats a confusing + // failure several phases later. + if (argv.projectId) assertSafeId(argv.projectId, "project ID"); + if (argv.orgId) assertSafeId(argv.orgId, "org ID"); + if (argv.branchId) assertSafeId(argv.branchId, "branch ID"); + // --data with a "step" field routes to the appropriate phase if (argv.data && isAgentMode) { let data: Record; @@ -143,10 +169,18 @@ export const handler = async (argv: { agent, skipMigrations: argv.skipMigrations, preview: argv.preview, + projectId: argv.projectId, + orgId: argv.orgId, + branchId: argv.branchId, }), ); } else { - await interactiveInit({ preview: argv.preview }); + await interactiveInit({ + preview: argv.preview, + projectId: argv.projectId, + orgId: argv.orgId, + branchId: argv.branchId, + }); } } catch (error) { const cause = error instanceof Error ? error : new Error(String(error)); diff --git a/packages/cli/src/init/__snapshots__/agent_snapshot.test.ts.snap b/packages/cli/src/init/__snapshots__/agent_snapshot.test.ts.snap index c6059cac..f675a4ce 100644 --- a/packages/cli/src/init/__snapshots__/agent_snapshot.test.ts.snap +++ b/packages/cli/src/init/__snapshots__/agent_snapshot.test.ts.snap @@ -14564,6 +14564,15 @@ Options: --preview └────────────────> Enable preview features (e.g. project bootstrapping from tem plates). [boolean] [default: false] +--project-id +└────────────────> Use an existing Neon project by ID. Skips organization and p + roject selection. [string] +--org-id +└────────────────> Scope setup to an existing organization by ID. Skips organiz + ation selection. [string] +--branch-id +└────────────────> Target a specific branch by ID when pulling environment vari + ables. [string] --- subprocesses --- " `; diff --git a/packages/cli/src/init/ids.ts b/packages/cli/src/init/ids.ts new file mode 100644 index 00000000..326d3f90 --- /dev/null +++ b/packages/cli/src/init/ids.ts @@ -0,0 +1,11 @@ +/** + * Validates that an ID contains only safe characters for shell interpolation. + * Neon org/project/branch IDs are UUIDs or slug-like strings. + */ +export function assertSafeId(value: string, label: string): void { + if (!/^[\w.:-]+$/.test(value)) { + throw new Error( + `Invalid ${label}: "${value}". Expected alphanumeric, hyphens, underscores, dots, or colons.`, + ); + } +} diff --git a/packages/cli/src/init/interactive.ts b/packages/cli/src/init/interactive.ts index b31fac9c..e4393366 100644 --- a/packages/cli/src/init/interactive.ts +++ b/packages/cli/src/init/interactive.ts @@ -185,6 +185,12 @@ async function selectTemplate( export type InteractiveInitOptions = { preview?: boolean; + /** Existing project to use — carried into the agent hand-off command. */ + projectId?: string; + /** Existing org to scope to — carried into the agent hand-off command. */ + orgId?: string; + /** Branch to target — carried into the agent hand-off command. */ + branchId?: string; }; export async function interactiveInit( @@ -783,6 +789,11 @@ async function interactiveInitInner( if (selectedFeatures.length > 0) gettingStartedData.features = selectedFeatures; if (options.preview) gettingStartedData.preview = true; + // Carry any explicitly provided IDs into the hand-off so the agent runs the + // verified fast path (single `neon link`) instead of the selection flow. + if (options.projectId) gettingStartedData.projectId = options.projectId; + if (options.orgId) gettingStartedData.orgId = options.orgId; + if (options.branchId) gettingStartedData.branchId = options.branchId; // Build a prompt for the user to paste into their agent chat const cmd = `neon init --agent --data '${JSON.stringify({ step: "getting-started", ...gettingStartedData })}'`; diff --git a/packages/cli/src/init/orchestrate.ts b/packages/cli/src/init/orchestrate.ts index 20733af7..0d978e56 100644 --- a/packages/cli/src/init/orchestrate.ts +++ b/packages/cli/src/init/orchestrate.ts @@ -15,6 +15,12 @@ export type OrchestratorOptions = { skipMigrations?: boolean; /** Enable preview features (e.g. project bootstrapping from templates) */ preview?: boolean; + /** Existing project to use — skips org/project selection in getting-started */ + projectId?: string; + /** Existing org to scope to — skips org selection in getting-started */ + orgId?: string; + /** Branch to target when pulling env */ + branchId?: string; }; /** @@ -132,6 +138,9 @@ export async function orchestrate( migrationDir: inspection.migrationDir as string | undefined, features, preview: options.preview, + projectId: options.projectId, + orgId: options.orgId, + branchId: options.branchId, }); } diff --git a/packages/cli/src/init/phases/db.ts b/packages/cli/src/init/phases/db.ts index 3522e69f..9b1c1d56 100644 --- a/packages/cli/src/init/phases/db.ts +++ b/packages/cli/src/init/phases/db.ts @@ -1,23 +1,13 @@ +import { assertSafeId } from "../ids.js"; import { neonctlCmd } from "../neonctl.js"; import { SKILL_REFERENCE_URLS } from "../skills.js"; import type { PhaseResponse } from "../types.js"; -/** - * Validates that an ID contains only safe characters for shell interpolation. - * Neon org/project IDs are typically UUIDs or slug-like strings. - */ -function assertSafeId(value: string, label: string): void { - if (!/^[\w.:-]+$/.test(value)) { - throw new Error( - `Invalid ${label}: "${value}". Expected alphanumeric, hyphens, underscores, dots, or colons.`, - ); - } -} - export type DbPhaseOptions = { agent?: string; orgId?: string; projectId?: string; + branchId?: string; orgsResult?: string; projectsResult?: string; framework?: string; @@ -35,6 +25,7 @@ export async function handleDbPhase( // Validate IDs that will be interpolated into shell commands if (options.projectId) assertSafeId(options.projectId, "project ID"); if (options.orgId) assertSafeId(options.orgId, "org ID"); + if (options.branchId) assertSafeId(options.branchId, "branch ID"); // Error from a previous step if (options.error) { @@ -59,10 +50,16 @@ export async function handleDbPhase( // If we have a project ID, we're in the "wire it up" phase if (options.projectId) { + const branchFlag = options.branchId + ? ` --branch-id ${options.branchId}` + : ""; return { phase: "db", status: "project_ready", - project: { id: options.projectId }, + project: { + id: options.projectId, + ...(options.branchId ? { branchId: options.branchId } : {}), + }, nextAction: { type: "agent_action", prerequisite: SKILL_REFERENCE_URLS.connectionMethods, @@ -70,7 +67,7 @@ export async function handleDbPhase( { id: "get_connection_string", description: "Get the database connection string", - command: `${neonctlCmd()} connection-string --project-id ${options.projectId}`, + command: `${neonctlCmd()} connection-string --project-id ${options.projectId}${branchFlag}`, }, { id: "store_env", diff --git a/packages/cli/src/init/phases/getting_started.ts b/packages/cli/src/init/phases/getting_started.ts index 6cba14f4..45dd5d76 100644 --- a/packages/cli/src/init/phases/getting_started.ts +++ b/packages/cli/src/init/phases/getting_started.ts @@ -5,6 +5,7 @@ import { MISSING_BINARY_HINT, resolvePackageManager, } from "../../utils/package_manager.js"; +import { assertSafeId } from "../ids.js"; import { neonctlCmd } from "../neonctl.js"; import { ensureSkillsUpToDate, SKILL_REFERENCE_URLS } from "../skills.js"; import type { PhaseResponse } from "../types.js"; @@ -20,6 +21,15 @@ export type GettingStartedPhaseOptions = { features?: string[]; /** Preview mode — restricts project creation to new projects in AWS us-east */ preview?: boolean; + /** + * Existing project to use. When set, org/project selection is skipped and a + * single verified `neon link` step writes the .neon context. + */ + projectId?: string; + /** Existing org to scope to. When set (without projectId), org selection is skipped. */ + orgId?: string; + /** Branch to target — recorded in the .neon context so `env pull` uses it. */ + branchId?: string; /** The project directory the emitted commands will run in. */ cwd: string; }; @@ -43,45 +53,41 @@ export async function handleGettingStartedPhase( const installPm = resolvePackageManager(options.cwd); if (!options.hasConnectionString) { - if (options.preview) { - // Public beta: platform features are only in AWS us-east-2 for now - steps.push( - { - id: "select_org", - description: [ - "List the user's Neon organizations using the CLI command below.", - "If only one org exists, use it automatically.", - "If multiple orgs exist, ask the user which one to use.", - "Remember the selected org ID for the next steps.", - ].join(" "), - command: `${neonctlCmd()} orgs list --output json`, - }, - { - id: "select_or_create_project", - description: [ - "List existing Neon projects in the selected organization using the CLI command below (replace with the selected org ID).", - "IMPORTANT: Neon features (Functions, Object Storage, and AI Gateway) are currently in beta and only available in the AWS us-east-2 region (more regions coming shortly). Projects must have region_id 'aws-us-east-2' and be created on or after 2026-06-15.", - "Filter the project list to ONLY show projects where region_id is 'aws-us-east-2' AND created_at is on or after '2026-06-15'.", - "If eligible projects exist, present them alongside a 'Create new project' option.", - "If no eligible projects exist, tell the user and proceed directly to creating a new one.", - "IMPORTANT: Always include --org-id when creating a project to avoid interactive prompts.", - ].join(" "), - command: `${neonctlCmd()} projects list --org-id --output json`, - }, - { - id: "create_project_if_needed", - description: [ - "If the user chose to create a new project, create it in the AWS us-east-2 region using the CLI command below (replace and ).", - "Ask the user for a project name (suggest the current directory name).", - "If the user chose an existing eligible project, skip this step.", - ].join(" "), - command: `${neonctlCmd()} projects create --name --org-id --region-id aws-us-east-2 --output json`, - }, - ); + if (options.projectId) { + // Verified fast path: the user named an existing project, so there is + // nothing to list or ask. A single `neon link` verifies the IDs against + // the account, infers the org, records the branch, and writes .neon — + // which makes the org/project selection steps and create_neon_context + // below unnecessary. + assertSafeId(options.projectId, "project ID"); + const linkFlags = [`--project-id ${options.projectId}`]; + if (options.orgId) { + assertSafeId(options.orgId, "org ID"); + linkFlags.push(`--org-id ${options.orgId}`); + } + if (options.branchId) { + assertSafeId(options.branchId, "branch ID"); + linkFlags.push(`--branch-id ${options.branchId}`); + } + steps.push({ + id: "link_project", + description: [ + "Link this directory to the existing Neon project using the CLI command below.", + "This verifies the IDs against the user's Neon account, infers the organization, and writes the .neon context file (merging into any existing file — do NOT overwrite it).", + "No organization or project selection is needed.", + ].join(" "), + command: `${neonctlCmd()} link ${linkFlags.join(" ")}`, + }); } else { - // Standard mode: let user choose existing or create new - steps.push( - { + // No project chosen yet. If an org was provided, skip org selection and + // scope the project commands to it; otherwise start by listing orgs. + const orgKnown = Boolean(options.orgId); + if (orgKnown) assertSafeId(options.orgId as string, "org ID"); + const orgRef = options.orgId ?? ""; + const orgFlag = `--org-id ${orgRef}`; + + if (!orgKnown) { + steps.push({ id: "select_org", description: [ "List the user's Neon organizations using the CLI command below.", @@ -90,39 +96,77 @@ export async function handleGettingStartedPhase( "Remember the selected org ID for the next steps.", ].join(" "), command: `${neonctlCmd()} orgs list --output json`, - }, - { - id: "select_or_create_project", - description: [ - "List existing Neon projects in the selected organization using the CLI command below (replace with the selected org ID).", - "Ask the user whether they want to use an existing project or create a new one.", - "If creating new, ask the user for a project name (suggest the current directory name).", - "IMPORTANT: Always include --org-id when creating a project to avoid interactive prompts.", - ].join(" "), - command: `${neonctlCmd()} projects list --org-id --output json`, - }, - { - id: "create_project_if_needed", - description: [ - "If the user chose to create a new project, create it using the CLI command below (replace and ).", - "If the user chose an existing project, skip this step.", - ].join(" "), - command: `${neonctlCmd()} projects create --name --org-id --output json`, - }, - ); - } + }); + } - // Create/update .neon context file - steps.push({ - id: "create_neon_context", - description: [ - "Update the .neon context file in the project root with the selected org and project IDs.", - "IMPORTANT: If a .neon file already exists, you MUST read it first, then merge the new orgId and projectId into the existing content. Do NOT overwrite the file — other fields (like _init, branch, etc.) must be preserved.", - "If no .neon file exists, create one.", - 'The file is JSON. Add/update only the orgId and projectId fields: {"orgId": "", "projectId": "", ...existing fields}.', - "This file is safe to commit — it contains no secrets.", - ].join(" "), - }); + const orgPhrase = orgKnown + ? `organization ${orgRef}` + : "selected organization"; + const listSuffix = orgKnown + ? "" + : " (replace with the selected org ID)"; + + if (options.preview) { + // Public beta: platform features are only in AWS us-east-2 for now + steps.push( + { + id: "select_or_create_project", + description: [ + `List existing Neon projects in the ${orgPhrase} using the CLI command below${listSuffix}.`, + "IMPORTANT: Neon features (Functions, Object Storage, and AI Gateway) are currently in beta and only available in the AWS us-east-2 region (more regions coming shortly). Projects must have region_id 'aws-us-east-2' and be created on or after 2026-06-15.", + "Filter the project list to ONLY show projects where region_id is 'aws-us-east-2' AND created_at is on or after '2026-06-15'.", + "If eligible projects exist, present them alongside a 'Create new project' option.", + "If no eligible projects exist, tell the user and proceed directly to creating a new one.", + "IMPORTANT: Always include --org-id when creating a project to avoid interactive prompts.", + ].join(" "), + command: `${neonctlCmd()} projects list ${orgFlag} --output json`, + }, + { + id: "create_project_if_needed", + description: [ + `If the user chose to create a new project, create it in the AWS us-east-2 region using the CLI command below${orgKnown ? "" : " (replace and )"}.`, + "Ask the user for a project name (suggest the current directory name).", + "If the user chose an existing eligible project, skip this step.", + ].join(" "), + command: `${neonctlCmd()} projects create --name ${orgFlag} --region-id aws-us-east-2 --output json`, + }, + ); + } else { + // Standard mode: let user choose existing or create new + steps.push( + { + id: "select_or_create_project", + description: [ + `List existing Neon projects in the ${orgPhrase} using the CLI command below${listSuffix}.`, + "Ask the user whether they want to use an existing project or create a new one.", + "If creating new, ask the user for a project name (suggest the current directory name).", + "IMPORTANT: Always include --org-id when creating a project to avoid interactive prompts.", + ].join(" "), + command: `${neonctlCmd()} projects list ${orgFlag} --output json`, + }, + { + id: "create_project_if_needed", + description: [ + `If the user chose to create a new project, create it using the CLI command below${orgKnown ? "" : " (replace and )"}.`, + "If the user chose an existing project, skip this step.", + ].join(" "), + command: `${neonctlCmd()} projects create --name ${orgFlag} --output json`, + }, + ); + } + + // Create/update .neon context file + steps.push({ + id: "create_neon_context", + description: [ + "Update the .neon context file in the project root with the selected org and project IDs.", + "IMPORTANT: If a .neon file already exists, you MUST read it first, then merge the new orgId and projectId into the existing content. Do NOT overwrite the file — other fields (like _init, branch, etc.) must be preserved.", + "If no .neon file exists, create one.", + 'The file is JSON. Add/update only the orgId and projectId fields: {"orgId": "", "projectId": "", ...existing fields}.', + "This file is safe to commit — it contains no secrets.", + ].join(" "), + }); + } // Install project dependencies (required before env pull — config files may import packages) steps.push({