Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/minimal-neon-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"neon": major
---

`neon init` is now minimal: it authenticates and installs the Neon tooling (MCP server, agent skills, and — in a VS Code–based IDE — the editor extension), then hands off to the agent. It no longer selects an organization or project, links the directory, pulls environment variables, asks which features to enable, or provisions Auth / Object Storage / Functions / AI Gateway — the installed Neon skill drives all of that from here (or you can run `neon link` yourself).

- Removed flags: `--preview`, `--project-id`, `--org-id`, `--branch-id`, and `--skip-migrations`.
- The agent `--data` step protocol is reduced to `auth`, `setup`, `mcp`, and `skills`. The `getting-started`, `db`, `migrations`, `neon-auth`, `status`, and `finalize` steps are removed.
- Interactive `neon init` no longer scaffolds from a template or configures Neon Auth; it installs tooling and points you at your agent (or `neon link`).

The standalone `neon bootstrap` command is unaffected.
237 changes: 66 additions & 171 deletions packages/cli/e2e/init.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,28 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { configuredOrgId } from "@neon/e2e-harness";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createProject,
deleteProject,
runCli,
uniqueProjectName,
} from "./helpers.js";
import { runCli } from "./helpers.js";

/**
* `neon init --agent` is a protocol rather than a command that does the work: it answers with
* the shell commands an agent should run on the user's behalf. The snapshot tests pin those
* strings exactly; what no test checks is that the commands still exist and still work. That
* is the regression this covers — init handing an agent an invocation the CLI no longer
* accepts, which the agent then reports as a failed setup.
*
* The phase under test is `getting-started`, because that is the one a real run reaches: a
* directory with no Neon connection routes there (`init/orchestrate.ts`), and it is where the
* org, project and env commands are emitted.
* `neon init --agent` answers with the next step. Snapshot tests pin the
* strings; this suite checks that asking does not install into the project,
* and that deleted steps stay unknown.
*/

/** The exact prefix the protocol emits, before `--profile` / `--config-dir`. Substituted for the binary under test — and asserted, because a silent change here would make every substitution below a no-op. */
const EMITTED_PREFIX = "CI= npx -y neon ";

/**
* Every variable `detectAgent` and `detectIde` read (`init/detect_agent.ts`). Clearing them
* keeps the run agentless, which matters for more than determinism: given an agent id, the
* phase calls `ensureSkillsUpToDate`, which fetches the skills CLI and installs skills — into
* the home directory, and into the working directory it is invoked from.
* Every variable `detectAgent` and `detectIde` read. Clearing them keeps the
* run agentless: with an agent id the setup execution path would fetch the
* skills CLI and install into home and cwd. The default `setup` step under
* test never executes installs; the scrub keeps that honest if that changes.
*
* This has to be applied to the phase invocation itself, not only to the commands it emits.
* It was not, once, and the run wrote `.agents/skills/` and `skills-lock.json` into
* `packages/cli`. Hence also the temp working directory below, and the assertion that nothing
* was installed into it: a scrub that silently stops working should fail this test rather
* than reconfigure the machine.
*
* The value scan at `detect_agent.ts:48` only runs inside the VS Code branch, which these
* variables gate, so clearing them is sufficient.
* The value scan in `detectIde` only runs inside the VS Code branch, which
* these variables gate.
*/
const NO_AGENT_ENV: Record<string, undefined> = {
CLAUDECODE: undefined,
Expand All @@ -64,177 +42,94 @@ const NO_AGENT_ENV: Record<string, undefined> = {
VSCODE_CWD: undefined,
};

type AgentStep = { id: string; description: string; command?: string };

type PhaseResponse = {
phase: string;
status: string;
nextAction?: {
type: string;
steps?: AgentStep[];
onComplete?: { type: string };
reportBack?: { type?: string; command?: string };
[key: string]: unknown;
};
};

describe.sequential("e2e — neon init emits commands that work", () => {
let projectId: string;
/** Projects the emitted `projects create` produced, removed in teardown. */
const created: string[] = [];
const orgId = configuredOrgId();

/**
* Everything runs here rather than in the checkout: `env pull` writes a connection string
* and password into it, and the phase would install skills here if the scrub above ever
* stopped working. Removed in teardown, credentials included.
*/
const workdir = mkdtempSync(join(tmpdir(), "neon-init-e2e-"));
describe.sequential("e2e — neon init emits a protocol the CLI still accepts", () => {
const root = mkdtempSync(join(tmpdir(), "neon-init-e2e-"));
const workdir = join(root, "app");
const contextFile = join(workdir, ".neon");
const home = join(root, "home");
const isolated = {
env: { ...NO_AGENT_ENV, HOME: home },
cwd: workdir,
contextFile,
json: false,
apiKey: null,
};

beforeAll(async () => {
// Make the workdir a pnpm project: the emitted install steps must follow
// it rather than the npm this suite used to assert, and the `.git` marker
// stops the lockfile walk from climbing into $TMPDIR's ancestors.
beforeAll(() => {
mkdirSync(workdir);
mkdirSync(home);
mkdirSync(join(workdir, ".git"));
writeFileSync(join(workdir, "pnpm-lock.yaml"), "");

projectId = await createProject({
name: uniqueProjectName("cli-init"),
});
writeFileSync(contextFile, `${JSON.stringify({ orgId, projectId })}\n`);
});

afterAll(async () => {
rmSync(workdir, { recursive: true, force: true });
for (const id of [...created, projectId]) {
if (id) await deleteProject(id);
}
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});

it("hands an agent a working org, project and env sequence", async () => {
if (!orgId) {
throw new Error(
"NEON_ORG_ID is required: the emitted commands take an --org-id, and substituting one is the point of this test.",
);
}

it("answers the setup step with an agent_check and installs nothing by asking", async () => {
const phase = await runCli(
[
"init",
"--agent",
"--data",
JSON.stringify({ step: "getting-started" }),
],
{ env: NO_AGENT_ENV, cwd: workdir, contextFile },
["init", "--agent", "--data", JSON.stringify({ step: "setup" })],
isolated,
);
expect(phase.code, phase.stderr).toBe(0);

const response = JSON.parse(phase.stdout) as PhaseResponse;
expect(response.phase).toBe("setup");
expect(response.nextAction?.type).toBe("agent_check");
expect(response.nextAction?.reportBack?.type).toBe("run_shell_command");
expect(response.nextAction?.reportBack?.command).toMatch(
/init --agent/,
);
expect(response.nextAction?.reportBack?.command).toContain(
'"step":"setup"',
);

// Nothing may have been installed by asking the phase what to do.
expect(existsSync(join(workdir, ".agents"))).toBe(false);
expect(existsSync(join(workdir, "skills-lock.json"))).toBe(false);
});

expect(response.nextAction?.type).toBe("agent_action");
const steps = response.nextAction?.steps ?? [];
const ids = steps.map((step) => step.id);
// The sequence a user is walked through. Pinned so a step disappearing from the
// flow fails here rather than quietly reducing what this test exercises.
expect(ids).toEqual(
expect.arrayContaining([
"select_org",
"select_or_create_project",
"create_project_if_needed",
"pull_env",
]),
it("rejects the removed getting-started step", async () => {
const result = await runCli(
[
"init",
"--agent",
"--data",
JSON.stringify({ step: "getting-started" }),
],
isolated,
);

const executed: string[] = [];
for (const step of steps) {
if (!step.command) continue;

// Install steps belong to the user's package manager, not to us. Assert we
// addressed the workdir's pnpm rather than running an install on the machine.
if (!step.command.startsWith(EMITTED_PREFIX)) {
expect(step.command).toMatch(/^pnpm (install|add) ?/);
continue;
}

const afterPrefix = step.command.slice(EMITTED_PREFIX.length);
// `runCli` always supplies `--config-dir`. Passing it again makes yargs
// give the option an array, which is the `path` TypeError this used to
// hit. The e2e config path has no quotes, so one quoted token is the value.
expect(afterPrefix, step.command).toMatch(/^--config-dir '/);
const withoutConfigDir = afterPrefix.replace(
/^--config-dir '[^']+' /,
"",
);

const args = withoutConfigDir
.replace("<org-id>", orgId)
.replace("<project-name>", uniqueProjectName("init-emitted"))
.split(/\s+/);

const result = await runCli(args, {
env: { ...NO_AGENT_ENV, CI: "" },
cwd: workdir,
contextFile,
});
expect(result.code, `${step.command}\n${result.stderr}`).toBe(0);
executed.push(step.id);

if (step.id === "select_org") {
const orgs = JSON.parse(result.stdout) as { id: string }[];
expect(orgs.map((org) => org.id)).toContain(orgId);
}
if (step.id === "select_or_create_project") {
// The phase tells the agent to filter this list, so it has to be parseable
// and it has to contain the project the agent would pick.
const projects = JSON.parse(result.stdout) as { id: string }[];
expect(projects.map((project) => project.id)).toContain(
projectId,
);
}
if (step.id === "create_project_if_needed") {
const { project } = JSON.parse(result.stdout) as {
project: { id: string; name: string };
};
created.push(project.id);
expect(project.name).toMatch(/^neon-ts-e2e-/);
}
if (step.id === "pull_env") {
// Exit 0 is not the outcome that matters: `env pull` succeeds when it
// resolves nothing. The whole sequence exists to leave a connection string
// on disk, so read the file it wrote.
const envFile = join(workdir, ".env.local");
expect(existsSync(envFile)).toBe(true);
expect(readFileSync(envFile, "utf8")).toMatch(
/^DATABASE_URL="?postgresql:\/\/.+/m,
);
}
}

// Every command the flow emits for us must have run, or this test proved less than
// it claims. `pull_env` last: it is the step that produces the connection string the
// whole sequence exists to obtain.
expect(executed).toEqual([
"select_org",
"select_or_create_project",
"create_project_if_needed",
"pull_env",
]);
expect(result.code).not.toBe(0);
const failure = JSON.parse(result.stdout) as {
success: boolean;
error: string;
};
expect(failure.success).toBe(false);
expect(failure.error).toContain('Unknown step: "getting-started"');
});

it("reports an unknown step instead of guessing", async () => {
const result = await runCli([
"init",
"--agent",
"--data",
JSON.stringify({ step: "not-a-real-step" }),
]);
const result = await runCli(
[
"init",
"--agent",
"--data",
JSON.stringify({ step: "not-a-real-step" }),
],
isolated,
);

expect(result.code).not.toBe(0);
// Agent mode answers in JSON even when it fails, so an agent parsing stdout gets a
// reason rather than a stack trace on stderr.
const failure = JSON.parse(result.stdout) as {
success: boolean;
error: string;
Expand Down
16 changes: 7 additions & 9 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,16 @@
},
"scripts": {
"generateParams": "tsx generateOptionsFromSpec.ts",
"codegen": "node scripts/set-vsx-gallery.mjs",
"clean": "rm -rf dist",
"build": "pnpm codegen && pnpm generateParams && tsc --noEmit && tsdown && cp src/*.html ./dist",
"build:internal": "node scripts/set-vsx-gallery.mjs https://cursor-vsx-proxy.cloud.databricks.com/gallery && pnpm generateParams && tsc --noEmit && tsdown && cp src/*.html ./dist",
"build": "pnpm generateParams && tsc --noEmit && tsdown && cp src/*.html ./dist",
"bundle": "node pkg.js",
"typecheck": "pnpm codegen && tsc --noEmit",
"lint": "pnpm codegen && pnpm typecheck && biome check src",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && biome check src",
"lint:fix": "pnpm typecheck && biome check src --write",
"test": "pnpm codegen && pnpm --filter neonctl... build && vitest run",
"test:ci": "pnpm codegen && pnpm build && vitest run",
"test:e2e": "pnpm codegen && pnpm build && vitest run --config vitest.e2e.config.ts",
"test:conformance": "pnpm codegen && vitest run --config tests/psql-conformance/vitest.config.ts"
"test": "pnpm --filter neonctl... build && vitest run",
"test:ci": "pnpm build && vitest run",
"test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts",
"test:conformance": "vitest run --config tests/psql-conformance/vitest.config.ts"
},
"dependencies": {
"@clack/core": "0.4.2",
Expand Down
15 changes: 0 additions & 15 deletions packages/cli/scripts/set-vsx-gallery.mjs

This file was deleted.

Loading
Loading