neon 2.42.0 → 2.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -1,27 +1,6 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
- import { configDir, resolveConfigFile } from "@neon/config/paths";
3
2
  import { isCi } from "./env.js";
4
- export const CREDENTIALS_FILE = "credentials.json";
5
- /**
6
- * Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
7
- *
8
- * The directory was called `neonctl` until the CLI was renamed. An existing one is still
9
- * read — see {@link credentialsPath} — but it is never written to, moved, or deleted.
10
- */
11
- export const defaultDir = configDir();
12
- /**
13
- * Where this invocation's `credentials.json` lives.
14
- *
15
- * When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
16
- * directory is used **in place**: an install that predates the rename keeps working, and
17
- * its credentials are never duplicated into a second location where one copy could go
18
- * stale while another tool still reads it.
19
- *
20
- * A `--config-dir` the user actually passed is used exactly as given. Falling back out of
21
- * an explicitly chosen directory would defeat the reason for choosing it — a CI run
22
- * pointed at a scratch directory must never pick up a developer's real credentials.
23
- */
24
- export const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
3
+ export { CREDENTIALS_FILE, credentialsPath, defaultDir, isInsideConfigDir, isOwnedCredentialPath, } from "./_shared/paths.js";
25
4
  export const ensureConfigDir = ({ "config-dir": configDirArg, "force-auth": forceAuth, }) => {
26
5
  if (!existsSync(configDirArg) && (!isCi() || forceAuth)) {
27
6
  mkdirSync(configDirArg, { recursive: true });
package/dist/context.js CHANGED
@@ -49,6 +49,11 @@ export const isConfigInit = (args) => args._[0] === "config" &&
49
49
  * access has already lapsed is the main reason to remove one.
50
50
  */
51
51
  export const isProfileCommand = (args) => args._[0] === "profile" || args._[0] === "profiles";
52
+ /**
53
+ * `neon api-keys …`, under either spelling. Exempts the group from context enrichment: how
54
+ * far a credential reaches must come from an explicit flag, never from `.neon`.
55
+ */
56
+ export const isApiKeysCommand = (args) => args._[0] === "api-keys" || args._[0] === "api-key";
52
57
  const CONTEXT_FILE = ".neon";
53
58
  const GITIGNORE_FILE = ".gitignore";
54
59
  const canAccessFile = (file) => {
@@ -116,6 +121,20 @@ export const enrichFromContext = (args) => {
116
121
  if (args._[0] === "link" || args._[0] === "set-context") {
117
122
  return;
118
123
  }
124
+ // `api-keys` mints credentials, and how far a credential reaches must be something the
125
+ // user typed — never something inherited from whichever project happens to be checked
126
+ // out. Enriched here, `api-keys create --name ci` in a linked directory would quietly
127
+ // produce a key scoped to that project instead of the account key it asked for.
128
+ if (isApiKeysCommand(args)) {
129
+ return;
130
+ }
131
+ // `profile create --mint` mints one too, and for the same reason must take its scope only
132
+ // from what was typed: enriched here, running it inside a linked directory would quietly
133
+ // produce a key scoped to that project rather than the account or organization asked for.
134
+ // No `profile` subcommand has any use for a project or branch.
135
+ if (isProfileCommand(args)) {
136
+ return;
137
+ }
119
138
  const context = readContextFile(args.contextFile);
120
139
  if (!args.orgId) {
121
140
  args.orgId = context.orgId;
package/dist/index.js CHANGED
@@ -4,8 +4,8 @@ import { hideBin } from "yargs/helpers";
4
4
  import { analyticsMiddleware, closeAnalytics, getAnalyticsEventProperties, initAnalyticsClientMiddleware, sendError, trackEvent, } from "./analytics.js";
5
5
  import { isNeonApiError, messageFromBody } from "./api.js";
6
6
  import { defaultClientID } from "./auth.js";
7
- import { credentialsToClearOn401, getAuthContext } from "./auth_context.js";
8
- import { deleteCredentials, ensureAuth } from "./commands/auth.js";
7
+ import { authFailureMessage, credentialsToClearOn401, getAuthContext, } from "./auth_context.js";
8
+ import { deleteCredentialsAt, ensureAuth } from "./commands/auth.js";
9
9
  import commands from "./commands/index.js";
10
10
  import { defaultDir, ensureConfigDir } from "./config.js";
11
11
  import { currentContextFile, enrichFromContext } from "./context.js";
@@ -96,6 +96,10 @@ builder = builder
96
96
  describe: "API key",
97
97
  group: "Global options:",
98
98
  type: "string",
99
+ // Take the next token as the value even when it looks like an option, so
100
+ // `--api-key -` binds the dash rather than being read as a command of its own.
101
+ // `profile create` gives `-` its meaning; everywhere else it is just a value.
102
+ nargs: 1,
99
103
  // The default must never be the value of NEON_API_KEY: yargs renders an
100
104
  // option's default into every help screen, so that printed the user's key
101
105
  // verbatim on `neon --help`. `resolveApiKeyFromEnv` reads the env var
@@ -187,17 +191,20 @@ async function handleError(msg, err) {
187
191
  }
188
192
  else if (err.status === 401) {
189
193
  sendError(err, "AUTH_FAILED");
190
- const configDir = credentialsToClearOn401(getAuthContext());
191
- // The request was authorized with a key the user supplied, so there
192
- // is nothing of ours to clear and nothing to retry — the same key
193
- // would just be rejected again.
194
- if (configDir === null) {
195
- log.error("Authentication failed: the Neon API rejected the API key. Check --api-key or NEON_API_KEY.");
194
+ const context = getAuthContext();
195
+ const staleCredentials = credentialsToClearOn401(context);
196
+ // The request was authorized with a key rather than a refreshable token — one the
197
+ // user supplied, or one stored in a profile. Either way there is nothing to clear
198
+ // and nothing to retry, since the same key would just be rejected again. Deleting
199
+ // a profile's key would destroy the only copy of a credential that cannot be
200
+ // refreshed, so the message says what to re-run instead.
201
+ if (staleCredentials === null) {
202
+ log.error(authFailureMessage(context));
196
203
  return false;
197
204
  }
198
205
  log.info("Authentication failed, deleting credentials...");
199
206
  try {
200
- deleteCredentials(configDir);
207
+ deleteCredentialsAt(staleCredentials);
201
208
  return true; // Allow retry for auth failures
202
209
  }
203
210
  catch (deleteErr) {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Helpers for the API-key half of a profile: naming a minted key, reading one off disk, and
3
+ * turning what the API says about a key into something displayable.
4
+ *
5
+ * Kept apart from `./commands/profile.ts` because these are the decisions worth testing
6
+ * directly — the command around them is prompts, an API client, and output.
7
+ */
8
+ import { randomBytes } from "node:crypto";
9
+ /** Auth methods that are an API key. Anything else is not a key and must not be stored as one. */
10
+ const API_KEY_METHODS = ["api_key_user", "api_key_org"];
11
+ export const isApiKeyMethod = (method) => API_KEY_METHODS.includes(method);
12
+ /**
13
+ * A unique name for a key we mint, carrying the profile it belongs to, a UTC timestamp, and a
14
+ * random suffix.
15
+ *
16
+ * Neither part is decoration. Key names are unique per account and the key being replaced still
17
+ * holds its name while the replacement is minted, so a stable name fails the rotation outright
18
+ * with `choose another unique name for api key`.
19
+ *
20
+ * The random suffix is there because a timestamp alone is not enough: at second precision two
21
+ * rotations in the same second collide, which is easy to hit from a script and was hit while
22
+ * testing this. Going to milliseconds only narrows the window, so the name carries four random
23
+ * hex characters and the class of failure is gone rather than made less likely.
24
+ */
25
+ export const mintedKeyName = (profile, now = new Date(), suffix = randomBytes(2).toString("hex")) => {
26
+ const stamp = now
27
+ .toISOString()
28
+ .replace(/[-:]/g, "")
29
+ .replace(/\.\d+Z$/, "Z");
30
+ return `neon-cli-${profile}-${stamp}-${suffix}`;
31
+ };
32
+ /**
33
+ * What to record about a validated key.
34
+ *
35
+ * An organization-scoped key has no user, and `GET /users/me` answers `404 not allowed for
36
+ * organization API keys` — so the account id is the only identity available, and asking for an
37
+ * email would turn a perfectly good key into a failed `profile create`. The id is returned bare
38
+ * rather than as "organization <id>": it already announces what it is through its `org-` prefix,
39
+ * and `list` shows the scope in the next column.
40
+ */
41
+ export const identityFromAuthDetails = (details, email) => {
42
+ if (details.auth_method === "api_key_org") {
43
+ return { label: details.account_id };
44
+ }
45
+ return {
46
+ ...(email ? { label: email } : { label: details.account_id }),
47
+ ...(details.account_id ? { userId: details.account_id } : {}),
48
+ };
49
+ };
50
+ /**
51
+ * The message for a credential that authenticates but is not an API key — an OAuth access
52
+ * token pasted in by mistake, most likely. It would work until it expired, then fail with no
53
+ * way to refresh it, so it is refused at the point where the mistake is still obvious.
54
+ */
55
+ export const notAnApiKeyMessage = (method) => `That credential authenticates as "${method}", not an API key. Create a key with \`neon api-keys create --name <name>\`, or have one minted with \`neon profile create <name> --mint\`.`;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Strict readers for flags whose value decides how much a credential can reach.
3
+ *
4
+ * These live apart from any one command because both `api-keys` and `profile create` mint
5
+ * keys, and a lenient reading of a scope flag has the same consequence in either: the flag
6
+ * reads as falsy, the scope check falls through, and an **account** key is minted instead of
7
+ * the narrow one that was asked for. Two copies of that check is one copy too many.
8
+ */
9
+ /**
10
+ * A flag is either absent, or exactly one non-empty string. Anything else is an error.
11
+ *
12
+ * - `--org-id ""` — an unset shell variable; empty string, which is falsy.
13
+ * - `--no-org-id` — yargs boolean negation; `false`, which is falsy.
14
+ * - `--org-id a --org-id b` — an array, which would reach the API as `a,b`.
15
+ *
16
+ * A misspelled flag never binds and cannot be seen here; `.strict()` rejects it. Anything
17
+ * after a `--` terminator is handled by {@link noPassthrough}.
18
+ */
19
+ export const single = (name, { required = false } = {}) => (value) => {
20
+ if (value === undefined)
21
+ return undefined;
22
+ if (Array.isArray(value)) {
23
+ throw new Error(`--${name} was given more than once. Pass it at most once.`);
24
+ }
25
+ // `--no-x` is the negation form and yields `false`, so name it rather than telling
26
+ // the user their value was empty when they never gave one.
27
+ if (value === false) {
28
+ throw new Error(required
29
+ ? `--no-${name} is not valid: --${name} is required.`
30
+ : `--no-${name} is not a valid way to skip --${name}. Omit the flag entirely.`);
31
+ }
32
+ if (typeof value !== "string" || value.trim() === "") {
33
+ throw new Error(required
34
+ ? `--${name} needs a value.`
35
+ : `--${name} needs a value. Pass one, or omit the flag entirely.`);
36
+ }
37
+ return value;
38
+ };
39
+ /**
40
+ * Refuse arguments after a `--` terminator.
41
+ *
42
+ * The CLI sets `populate--`, so everything past `--` lands in `argv["--"]` where `.strict()`
43
+ * never looks — `create --name x -- --project-id p` would parse cleanly and mint an account
44
+ * key from a line that names the scope flag.
45
+ */
46
+ export const noPassthrough = (command) => (argv) => {
47
+ const rest = argv["--"];
48
+ if (Array.isArray(rest) && rest.length > 0) {
49
+ throw new Error(`${command} takes no arguments after \`--\`, and options placed there are ignored rather than applied. Remove the \`--\`.`);
50
+ }
51
+ return true;
52
+ };
@@ -1,15 +1,29 @@
1
+ import { recordCredentialInputs } from "../_shared/auth_selection.js";
1
2
  /**
2
3
  * Resolves `--api-key` from `NEON_API_KEY` when the flag is absent, leaving it an
3
4
  * empty string when neither is set.
4
5
  *
5
6
  * This cannot be expressed as the option's yargs `default`, because yargs prints
6
7
  * defaults in help output and would print the key itself.
8
+ *
9
+ * This is also the one place that reads the credential environment, and it records what it saw
10
+ * before folding anything together. Folding destroys the distinction the precedence rules turn
11
+ * on — `--api-key` outranks `--profile` and an exported `NEON_API_KEY` does not, but once both
12
+ * live in `args.apiKey` they are indistinguishable. The snapshot lives outside `args`
13
+ * deliberately: a hidden yargs option would be a second, undocumented way to pass a
14
+ * credential, and commands that call `.strict()` reject arguments the middleware invents.
7
15
  */
8
16
  export const resolveApiKeyFromEnv = (args) => {
9
- if (typeof args.apiKey === "string" && args.apiKey !== "") {
17
+ const fromFlag = typeof args.apiKey === "string" ? args.apiKey : "";
18
+ const fromEnv = process.env.NEON_API_KEY ?? "";
19
+ recordCredentialInputs({
20
+ apiKeyFlag: fromFlag,
21
+ apiKeyEnv: fromEnv,
22
+ profileEnv: process.env.NEON_PROFILE ?? "",
23
+ });
24
+ if (fromFlag !== "") {
10
25
  return;
11
26
  }
12
- const fromEnv = process.env.NEON_API_KEY ?? "";
13
27
  args.apiKey = fromEnv;
14
28
  args["api-key"] = fromEnv;
15
29
  };
@@ -48,12 +48,19 @@ export const addDependenciesArgs = (pm, packages) => (pm === "npm" ? ["install",
48
48
  * exited cleanly; a non-zero exit is reported but never throws — the caller
49
49
  * decides whether to treat it as fatal.
50
50
  */
51
- export const runCommand = (cmd, args, cwd) => new Promise((resolvePromise) => {
51
+ export const runCommand = (cmd, args, cwd,
52
+ /**
53
+ * Extra environment for the child. Anything secret belongs here rather than in `args`:
54
+ * arguments are visible to any process that can list processes, and both handlers below
55
+ * print the full argument list when the command fails.
56
+ */
57
+ env) => new Promise((resolvePromise) => {
52
58
  // npm/pnpm/yarn ship as .cmd shims on Windows, which need a shell to run.
53
59
  const child = spawn(cmd, args, {
54
60
  cwd,
55
61
  stdio: "inherit",
56
62
  shell: process.platform === "win32",
63
+ ...(env ? { env: { ...process.env, ...env } } : {}),
57
64
  });
58
65
  child.on("error", (err) => {
59
66
  log.warning("Could not run `%s %s`: %s", cmd, args.join(" "), err instanceof Error ? err.message : String(err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "2.42.0",
3
+ "version": "2.44.0",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -55,11 +55,11 @@
55
55
  "which": "3.0.1",
56
56
  "yaml": "^2.9.0",
57
57
  "yargs": "17.7.2",
58
- "@neon/sdk": "1.4.1",
59
- "@neon/config": "0.13.1",
60
- "@neon/config-runtime": "0.12.2",
61
- "@neon/env": "0.13.2",
62
- "neon-init": "0.20.6"
58
+ "@neon/config-runtime": "0.12.4",
59
+ "@neon/env": "0.14.0",
60
+ "@neon/sdk": "1.5.0",
61
+ "neon-init": "0.20.7",
62
+ "@neon/config": "0.14.0"
63
63
  },
64
64
  "optionalDependencies": {
65
65
  "esbuild": "0.28.1"
@@ -112,14 +112,14 @@
112
112
  "scripts": {
113
113
  "generateParams": "tsx generateOptionsFromSpec.ts",
114
114
  "clean": "rm -rf dist",
115
- "build": "pnpm generateParams && pnpm clean && tsc -p tsconfig.build.json && cp src/*.html ./dist",
115
+ "build": "node ../../scripts/sync-shared.mjs . && pnpm generateParams && pnpm clean && tsc -p tsconfig.build.json && cp src/*.html ./dist",
116
116
  "bundle": "node pkg.js",
117
- "typecheck": "tsc --noEmit",
118
- "lint": "pnpm typecheck && biome check src",
117
+ "typecheck": "node ../../scripts/sync-shared.mjs . && tsc --noEmit",
118
+ "lint": "node ../../scripts/sync-shared.mjs . && pnpm typecheck && biome check src",
119
119
  "lint:fix": "pnpm typecheck && biome check src --write",
120
- "test": "pnpm --filter neonctl... build && vitest run",
121
- "test:ci": "pnpm build && vitest run",
122
- "test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts",
123
- "test:conformance": "vitest run --config tests/psql-conformance/vitest.config.ts"
120
+ "test": "node ../../scripts/sync-shared.mjs . && pnpm --filter neonctl... build && vitest run",
121
+ "test:ci": "node ../../scripts/sync-shared.mjs . && pnpm build && vitest run",
122
+ "test:e2e": "node ../../scripts/sync-shared.mjs . && pnpm build && vitest run --config vitest.e2e.config.ts",
123
+ "test:conformance": "node ../../scripts/sync-shared.mjs . && vitest run --config tests/psql-conformance/vitest.config.ts"
124
124
  }
125
125
  }