Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e881647
Add claimable project workflow to Neon CLI
andrelandgraf Aug 11, 2026
3e8c663
Call Claimable Neon /v1/projects paths.
andrelandgraf Aug 16, 2026
b2640f5
Refine Claimable Neon comments
andrelandgraf Aug 16, 2026
d44a4de
Accept a Claimable Neon registration without a claim object.
andrelandgraf Aug 16, 2026
5aed900
Fix claimable recovery copy and keep accept succeeding without a brow…
andrelandgraf Aug 16, 2026
eff578e
Read Claimable Neon errors from NeonApiError.body.
andrelandgraf Aug 16, 2026
3de8032
Show denied capabilities and warn on ambient account credentials.
andrelandgraf Aug 16, 2026
37a4e38
Name the claim-then-relink path and warn when ambient credentials win.
andrelandgraf Aug 16, 2026
1d65e47
Create the config directory before writing a claimable assertion.
andrelandgraf Aug 16, 2026
b2af05e
Keep claim create from telling agents to unset a working Neon login.
andrelandgraf Aug 17, 2026
5190fa2
Keep neon claim on the writer tables, not box-drawing output.
andrelandgraf Aug 25, 2026
24a2de1
Tell agents to run neon claim status after sign-in.
andrelandgraf Aug 25, 2026
7a96ceb
Omit Denied Capabilities from claim create when none are denied.
andrelandgraf Aug 25, 2026
f8fab13
Shorten claim comments and drop the assertion-location reassurance.
andrelandgraf Aug 25, 2026
6b1f2d1
Clear expired claim records and accept a project id from the list.
andrelandgraf Aug 25, 2026
b188b70
Create the claim e2e workspace before spawning the CLI.
andrelandgraf Aug 25, 2026
1342219
Show expired versus unclaimed on neon claim list.
andrelandgraf Aug 25, 2026
66d8732
Print CLI service names and consult both expiry clocks on claim list.
andrelandgraf Aug 25, 2026
a8cfffe
Avoid Object.hasOwn so claim.ts typechecks on the CLI lib target.
andrelandgraf Aug 25, 2026
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
8 changes: 8 additions & 0 deletions .changeset/calm-bears-claim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"neon": minor
"@neon/config": patch
---

Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. `status`, `accept`, and `delete` take an optional project id from `claim list`. `list` prints `state` from the assertion clock and the project expiry, and `delete` drops a local record after the identity assertion expires. `create` prints CLI service names and `project_expires_at`.

Recognize Claimable Neon capability errors in Config-as-Code so unavailable pre-claim services keep their actionable claim guidance instead of being reported as API-key failures.
52 changes: 52 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,57 @@ neon projects list --api-key <neon_api_key>

For information about obtaining an Neon API key, see [Authentication](https://neon.com/docs/reference/api/get-started), in the _Neon API Reference_.

## Create a project without an account

`neon claim create` provisions a temporary Claimable Neon project for an agent without
requiring a Neon account or opening a browser:

```bash
# Lakebase Postgres is always included
neon claim create

# Request Managed Better Auth and the Data API too
neon claim create --service auth --service data-api
```

When the current directory has a `neon.ts`, `claim create` also requests every service
declared there. Explicit `--service` values are added to that set. Object Storage, Functions,
and the AI Gateway are sent to the service so demand is recorded, but are reported as
unavailable until the project is claimed; the CLI does not silently remove them.

The command writes:

- a `.neon` context that identifies the project and Claimable Neon service;
- an owner-only identity assertion under the CLI config directory;
- `DATABASE_URL` and any granted Auth or Data API variables to `.env` or `.env.local`
(disable this with `--no-env-pull`).

Subsequent project commands automatically exchange the assertion for a short-lived agent
token and send API calls to Claimable Neon. The service decides which operations are
allowed before claim.

```bash
neon claim status # lifecycle and transfer status
neon projects get <project-id> # regular CLI command, same agent token
neon psql --role-name neondb_owner -- -c "select now()"
neon config plan
neon env pull --service postgres --service auth --service data-api

neon claim accept # create a claim code and open the transfer URL
neon claim delete --yes # permanently delete an unclaimed project
neon claim list # local records, including expired
neon claim delete <project-id> --yes
```

`status`, `accept`, and `delete` take an optional project id from `claim list`, so a
project stays manageable after its original directory is gone. `list` prints `state`
(`unclaimed` or `expired`) from the identity assertion clock and the project
expiry, plus `project_expires_at`. `delete` also drops a
local record whose identity assertion has expired or been revoked.

`neon claimable` is an alias for `neon claim`. For local service development, set
`CLAIMABLE_NEON_HOST=http://localhost:8787`; non-local origins must use HTTPS.

## Project and branch creation

Choose the PostgreSQL version when creating a project:
Expand Down Expand Up @@ -1145,6 +1196,7 @@ Id Name Project Created At Last Used At Last
| Command | Subcommands | Description |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
| [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
| claim (`claimable`) | `create`, `status`, `accept`, `list`, `delete` | Manage claimable projects |
| profile | `list`, `create`, `rotate-key`, `remove` | Manage named sets of credentials |
| api-keys | `list`, `create`, `revoke` | Manage API keys |
| [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
Expand Down
158 changes: 158 additions & 0 deletions packages/cli/e2e/claim.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect } from "vitest";
import { e2eTest, runCli } from "./helpers.js";

type CreatedClaim = {
project_id: string;
branch_id: string;
state: string;
};

type ClaimStatus = {
project_id: string;
state: string;
reconciled: boolean;
};

type BareProject = {
id: string;
};

const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) cleanups.shift()?.();
});

const isolatedDirs = (): {
configDir: string;
contextFile: string;
cwd: string;
} => {
const root = mkdtempSync(join(tmpdir(), "neon-claim-e2e-"));
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, "config");
const cwd = join(root, "workspace");
mkdirSync(configDir);
mkdirSync(cwd);
return {
configDir,
contextFile: join(root, ".neon"),
cwd,
};
};

const anonymous = {
apiKey: null,
env: {
NEON_API_KEY: undefined,
NEON_PROFILE: undefined,
},
} as const;

const claimHostArgs = (): string[] => {
const host = process.env.CLAIMABLE_NEON_HOST;
return host ? ["--claimable-host", host] : [];
};

const runAnonymousJson = async <T>(
args: string[],
dirs: { configDir: string; contextFile: string; cwd?: string },
): Promise<T> => {
const result = await runCli(args, {
...anonymous,
configDir: dirs.configDir,
contextFile: dirs.contextFile,
...(dirs.cwd ? { cwd: dirs.cwd } : {}),
});
if (result.code !== 0) {
throw new Error(
`neon ${args.join(" ")} exited ${result.code}\n${result.stderr || result.stdout}`,
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`neon ${args.join(" ")} did not print JSON:\n${result.stdout}`,
);
}
};

describe.sequential("e2e — neon claim against live Claimable Neon", () => {
e2eTest(
"creates, uses through ensureAuth, reports status, and deletes by project id",
async () => {
const createdIn = isolatedDirs();
let projectId: string | undefined;
try {
const created = await runAnonymousJson<CreatedClaim>(
["claim", "create", "--no-env-pull", ...claimHostArgs()],
createdIn,
);
projectId = created.project_id;
expect(created.state).toBe("unclaimed");

const fetched = await runAnonymousJson<BareProject>(
["projects", "get", created.project_id],
createdIn,
);
expect(fetched.id).toBe(created.project_id);

const liveStatus = await runAnonymousJson<ClaimStatus>(
["claim", "status", ...claimHostArgs()],
createdIn,
);
expect(liveStatus).toMatchObject({
project_id: created.project_id,
reconciled: false,
});
expect(liveStatus.state).not.toBe("expired");

const orphaned = isolatedDirs();
const deleted = await runAnonymousJson<{
project_id: string;
state: string;
}>(
[
"claim",
"delete",
created.project_id,
"--yes",
...claimHostArgs(),
],
{ ...orphaned, configDir: createdIn.configDir },
);
expect(deleted).toEqual({
project_id: created.project_id,
state: "deleted",
});
projectId = undefined;

const listed = await runAnonymousJson<unknown[]>(
["claim", "list"],
{ ...orphaned, configDir: createdIn.configDir },
);
expect(listed).toEqual([]);
} finally {
if (projectId !== undefined) {
await runCli(
[
"claim",
"delete",
projectId,
"--yes",
...claimHostArgs(),
],
{
...anonymous,
configDir: createdIn.configDir,
contextFile: createdIn.contextFile,
},
);
}
}
},
);
});
14 changes: 14 additions & 0 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ export function messageFromBody(body: unknown): string | undefined {
const message = body.message;
if (typeof message === "string") return message;
}
if (body && typeof body === "object" && "error" in body) {
const error = body.error;
if (error && typeof error === "object" && "message" in error) {
const message = error.message;
if (typeof message === "string") return message;
}
}
return undefined;
}

Expand All @@ -136,6 +143,13 @@ export function codeFromBody(body: unknown): string | undefined {
const code = body.code;
if (typeof code === "string") return code;
}
if (body && typeof body === "object" && "error" in body) {
const error = body.error;
if (error && typeof error === "object" && "code" in error) {
const code = error.code;
if (typeof code === "string") return code;
}
}
return undefined;
}

Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/auth_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { isOwnedCredentialPath } from "./config.js";
* The 401 handler runs outside yargs, so it needs the exact authentication source
* to avoid clearing DEFAULT after a named-profile failure.
*/
export type AuthSource = "api-key" | "profile-api-key" | "stored-credentials";
export type AuthSource =
| "api-key"
| "profile-api-key"
| "stored-credentials"
| "claimable";

export type AuthContext = {
source: AuthSource;
Expand Down Expand Up @@ -80,6 +84,10 @@ export const authFailureMessage = (context: AuthContext | null): string => {
return `Authentication failed: the Neon API rejected profile "${profile}"'s API key${where}. Replace it with \`neon profile create ${profile} --mint\`, or store another with \`neon profile create ${profile} --api-key -\`.`;
}

if (context?.source === "claimable") {
return `Authentication failed: Claimable Neon rejected the linked project's short-lived access token${where}. Retry the command to exchange the saved identity assertion again; if it still fails, run \`neon claim status\`.`;
}

// Reached only when the session was not ours to clear, i.e. an adopted credentials file.
// Saying "check --api-key" there would be nonsense; the fix is to sign in again.
if (context?.source === "stored-credentials") {
Expand Down
Loading
Loading