Skip to content
Closed
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
16 changes: 15 additions & 1 deletion packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ describe("init", () => {
agent: "cursor",
skipMigrations: undefined,
preview: undefined,
projectId: undefined,
orgId: undefined,
branchId: undefined,
});
expect(interactiveInit).not.toHaveBeenCalled();
});
Expand All @@ -96,6 +99,9 @@ describe("init", () => {
agent: "claude",
skipMigrations: true,
preview: undefined,
projectId: undefined,
orgId: undefined,
branchId: undefined,
});
});

Expand All @@ -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 () => {
Expand All @@ -120,6 +131,9 @@ describe("init", () => {
agent: "cursor",
skipMigrations: undefined,
preview: true,
projectId: undefined,
orgId: undefined,
branchId: undefined,
});
});

Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
"
`;
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/init/ids.ts
Original file line number Diff line number Diff line change
@@ -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.`,
);
}
}
11 changes: 11 additions & 0 deletions packages/cli/src/init/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 })}'`;
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/init/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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,
});
}

Expand Down
25 changes: 11 additions & 14 deletions packages/cli/src/init/phases/db.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand All @@ -59,18 +50,24 @@ 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,
steps: [
{
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",
Expand Down
Loading
Loading