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/README.md +181 -12
- package/dist/_shared/auth_selection.js +86 -0
- package/dist/_shared/credentials.js +209 -0
- package/dist/_shared/paths.js +149 -0
- package/dist/{profiles.js → _shared/profiles.js} +121 -35
- package/dist/_shared/secure_file.js +43 -0
- package/dist/analytics.js +16 -6
- package/dist/api.js +98 -10
- package/dist/auth_context.js +53 -8
- package/dist/commands/api_keys.js +349 -0
- package/dist/commands/auth.js +131 -59
- package/dist/commands/bootstrap.js +16 -3
- package/dist/commands/index.js +2 -0
- package/dist/commands/init.js +17 -0
- package/dist/commands/profile.js +831 -41
- package/dist/config.js +1 -22
- package/dist/context.js +19 -0
- package/dist/index.js +16 -9
- package/dist/profile_keys.js +55 -0
- package/dist/utils/flags.js +52 -0
- package/dist/utils/middlewares.js +16 -2
- package/dist/utils/package_manager.js +8 -1
- package/package.json +13 -13
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
|
|
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 {
|
|
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
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/
|
|
59
|
-
"@neon/
|
|
60
|
-
"@neon/
|
|
61
|
-
"
|
|
62
|
-
"neon
|
|
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
|
}
|