neon 4.13.0 → 4.14.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 +55 -13
- package/dist/_chunks/{credential_io-YeAxg9Xn.js → credential_io-D8AR-jvB.js} +4 -4
- package/dist/_chunks/{env-NbA61JR3.js → env-CZmstYrY.js} +71 -59
- package/dist/_chunks/{env_services-Tz9G4JeT.js → env_services-CAWrZTWa.js} +138 -41
- package/dist/_shared/auth_selection.js +83 -0
- package/dist/_shared/credentials.js +188 -0
- package/dist/_shared/paths.js +151 -0
- package/dist/_shared/profiles.js +226 -0
- package/dist/_shared/secure_file.js +41 -0
- package/dist/analytics.js +2 -2
- package/dist/auth.js +101 -11
- package/dist/auth_context.js +2 -11
- package/dist/claimable/api.js +2 -1
- package/dist/commands/ask.js +2 -1
- package/dist/commands/auth.js +155 -63
- package/dist/commands/checkout.js +28 -9
- package/dist/commands/claim.js +11 -7
- package/dist/commands/config.js +9 -9
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy_help.js +10 -0
- package/dist/commands/dev.js +147 -36
- package/dist/commands/env.js +33 -44
- package/dist/commands/functions.js +63 -2
- package/dist/commands/profile.js +2 -2
- package/dist/config_services.js +36 -1
- package/dist/credential_io.js +1 -1
- package/dist/custom_domains_api.js +37 -0
- package/dist/dev/env.js +1 -1
- package/dist/dev/functions.js +28 -11
- package/dist/dev/websocket.js +1 -1
- package/dist/env_services.js +2 -2
- package/dist/index.js +73 -20
- package/dist/init/build_config.js +4 -0
- package/dist/neon_services.js +2 -2
- package/dist/psql/wire/connection.js +1 -1
- package/dist/refresh_lock.js +44 -0
- package/dist/retire_credential.js +1 -1
- package/dist/test_utils/neon_api_server.js +60 -0
- package/dist/test_utils/rotating_oauth_server.js +94 -0
- package/dist/utils/config_diff.js +2 -13
- package/package.json +7 -5
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
//#region src/_shared/paths.ts
|
|
4
|
+
/**
|
|
5
|
+
* # Where the Neon CLIs keep their files on disk
|
|
6
|
+
*
|
|
7
|
+
**Deliberately impure.** It reads environment variables and touches the filesystem, which
|
|
8
|
+
* `@neon/config` — the package this used to be a subpath of — must never do from its root
|
|
9
|
+
* export. It lives here instead of there precisely so that a policy-facing package does not
|
|
10
|
+
* carry implementor-only code.
|
|
11
|
+
*
|
|
12
|
+
* It exists because three separate readers each grew their own answer to "where is the
|
|
13
|
+
* config directory", and all three disagreed: `packages/cli` honoured `XDG_CONFIG_HOME` but
|
|
14
|
+
* not `NEONCTL_CONFIG_DIR`, `packages/env` honoured the env var but not XDG, and the init
|
|
15
|
+
* flow hardcoded `~/.config/neonctl`. With `XDG_CONFIG_HOME` set, the CLI wrote
|
|
16
|
+
* credentials somewhere the other two never looked.
|
|
17
|
+
*
|
|
18
|
+
* ## The directory
|
|
19
|
+
*
|
|
20
|
+
* `neon` is the current name; `neonctl` is the legacy one, kept readable forever. Resolution,
|
|
21
|
+
* each entry winning over the next:
|
|
22
|
+
*
|
|
23
|
+
* 1. An explicit directory (a `--config-dir` flag) — **exact**, no legacy fallback.
|
|
24
|
+
* 2. `NEON_CONFIG_DIR` — exact.
|
|
25
|
+
* 3. `NEONCTL_CONFIG_DIR` (legacy name) — exact.
|
|
26
|
+
* 4. `$XDG_CONFIG_HOME/neon`, else `<home>/.config/neon`.
|
|
27
|
+
*
|
|
28
|
+
* An explicitly chosen directory is never paired with a fallback: `--config-dir /tmp/ci` that
|
|
29
|
+
* quietly read `~/.config/neonctl` would defeat the point of passing it.
|
|
30
|
+
*
|
|
31
|
+
* ## The files
|
|
32
|
+
*
|
|
33
|
+
* {@link resolveConfigFile} answers "which path should I use for this file", and it is the
|
|
34
|
+
* same answer for reading and writing:
|
|
35
|
+
*
|
|
36
|
+
* - Present in `neon/` → use it.
|
|
37
|
+
* - Present only in `neonctl/` → **use it there, in place.** An existing credentials file is
|
|
38
|
+
* never copied or moved, so nothing is left behind to go stale and no other tool starts
|
|
39
|
+
* reading an abandoned token.
|
|
40
|
+
* - Present in neither → the new location. New files only ever appear under `neon/`.
|
|
41
|
+
*/
|
|
42
|
+
/** Current directory name. New files are created here. */
|
|
43
|
+
const CONFIG_DIR_NAME = "neon";
|
|
44
|
+
/** Legacy directory name, read forever so existing installs keep working untouched. */
|
|
45
|
+
const LEGACY_CONFIG_DIR_NAME = "neonctl";
|
|
46
|
+
/** Where files are created. See the module docs for the precedence. */
|
|
47
|
+
function configDir(options = {}) {
|
|
48
|
+
const explicit = explicitDir(options);
|
|
49
|
+
if (explicit) return explicit;
|
|
50
|
+
return join(configHome(options.env ?? process.env), CONFIG_DIR_NAME);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The legacy directory, or `undefined` when the location was chosen explicitly (in which
|
|
54
|
+
* case there is no legacy counterpart to fall back to).
|
|
55
|
+
*/
|
|
56
|
+
function legacyConfigDir(options = {}) {
|
|
57
|
+
if (explicitDir(options)) return void 0;
|
|
58
|
+
return join(configHome(options.env ?? process.env), LEGACY_CONFIG_DIR_NAME);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve one file inside the config directory. Prefers the current location, falls back to
|
|
62
|
+
* an existing legacy file **in place**, and otherwise points at the current location so new
|
|
63
|
+
* files are created there.
|
|
64
|
+
*/
|
|
65
|
+
function resolveConfigFile(fileName, options = {}) {
|
|
66
|
+
const dir = configDir(options);
|
|
67
|
+
const current = resolve(dir, fileName);
|
|
68
|
+
if (existsSync(current)) return {
|
|
69
|
+
path: current,
|
|
70
|
+
dir,
|
|
71
|
+
isLegacy: false,
|
|
72
|
+
exists: true
|
|
73
|
+
};
|
|
74
|
+
const legacyDir = legacyConfigDir(options);
|
|
75
|
+
if (legacyDir) {
|
|
76
|
+
const legacy = resolve(legacyDir, fileName);
|
|
77
|
+
if (existsSync(legacy)) return {
|
|
78
|
+
path: legacy,
|
|
79
|
+
dir: legacyDir,
|
|
80
|
+
isLegacy: true,
|
|
81
|
+
exists: true
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
path: current,
|
|
86
|
+
dir,
|
|
87
|
+
isLegacy: false,
|
|
88
|
+
exists: false
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** `$XDG_CONFIG_HOME`, else `<home>/.config`. Falls back to a relative `.config` with no home. */
|
|
92
|
+
function configHome(env) {
|
|
93
|
+
const xdg = nonEmpty(env.XDG_CONFIG_HOME);
|
|
94
|
+
if (xdg) return xdg;
|
|
95
|
+
const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
|
|
96
|
+
return home ? join(home, ".config") : ".config";
|
|
97
|
+
}
|
|
98
|
+
function explicitDir(options) {
|
|
99
|
+
const env = options.env ?? process.env;
|
|
100
|
+
return nonEmpty(options.dir) ?? nonEmpty(env.NEON_CONFIG_DIR) ?? nonEmpty(env.NEONCTL_CONFIG_DIR);
|
|
101
|
+
}
|
|
102
|
+
function nonEmpty(value) {
|
|
103
|
+
if (typeof value !== "string") return void 0;
|
|
104
|
+
const trimmed = value.trim();
|
|
105
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
106
|
+
}
|
|
107
|
+
const CREDENTIALS_FILE = "credentials.json";
|
|
108
|
+
/**
|
|
109
|
+
* Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
|
|
110
|
+
*
|
|
111
|
+
* The directory was called `neonctl` until the CLI was renamed. An existing one is still read —
|
|
112
|
+
* see {@link credentialsPath} — but it is never written to, moved, or deleted.
|
|
113
|
+
*/
|
|
114
|
+
const defaultDir = configDir();
|
|
115
|
+
/**
|
|
116
|
+
* Where this invocation's `credentials.json` lives.
|
|
117
|
+
*
|
|
118
|
+
* When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
|
|
119
|
+
* directory is used **in place**: an install that predates the rename keeps working, and its
|
|
120
|
+
* credentials are never duplicated into a second location where one copy could go stale while
|
|
121
|
+
* another tool still reads it.
|
|
122
|
+
*
|
|
123
|
+
* A `--config-dir` the user actually passed is used exactly as given. Falling back out of an
|
|
124
|
+
* explicitly chosen directory would defeat the reason for choosing it — a CI run pointed at a
|
|
125
|
+
* scratch directory must never pick up a developer's real credentials.
|
|
126
|
+
*/
|
|
127
|
+
const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
128
|
+
/**
|
|
129
|
+
* Whether a credentials file is one the CLI created, rather than a path a profile adopted.
|
|
130
|
+
*
|
|
131
|
+
* Anything that deletes a credential has to ask this first. A profile entry may point anywhere —
|
|
132
|
+
* that is what makes adopting an existing directory a one-line edit — and a file we did not
|
|
133
|
+
* create is not ours to remove.
|
|
134
|
+
*/
|
|
135
|
+
const isInsideConfigDir = (configDirectory, file) => `${resolve(file)}/`.startsWith(`${resolve(configDirectory)}/`);
|
|
136
|
+
/**
|
|
137
|
+
* Whether a credentials file is one the CLI owns, counting the legacy `neonctl` directory.
|
|
138
|
+
*
|
|
139
|
+
* {@link credentialsPath} deliberately reads an existing legacy file in place rather than
|
|
140
|
+
* migrating it, so for a default config directory that file is ours even though it sits outside
|
|
141
|
+
* `neon/`. Judging ownership on the current directory alone would call an install that predates
|
|
142
|
+
* the rename "adopted".
|
|
143
|
+
*/
|
|
144
|
+
const isOwnedCredentialPath = (configDirectory, file) => {
|
|
145
|
+
if (isInsideConfigDir(configDirectory, file)) return true;
|
|
146
|
+
if (configDirectory !== defaultDir) return false;
|
|
147
|
+
const legacy = legacyConfigDir();
|
|
148
|
+
return legacy !== void 0 && isInsideConfigDir(legacy, file);
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { CONFIG_DIR_NAME, CREDENTIALS_FILE, LEGACY_CONFIG_DIR_NAME, configDir, credentialsPath, defaultDir, isInsideConfigDir, isOwnedCredentialPath, legacyConfigDir, resolveConfigFile };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { credentialsPath, defaultDir, resolveConfigFile } from "./paths.js";
|
|
2
|
+
import { writeSecretFile } from "./secure_file.js";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
//#region src/_shared/profiles.ts
|
|
6
|
+
/**
|
|
7
|
+
* # Profiles — several Neon accounts in one config directory
|
|
8
|
+
*
|
|
9
|
+
* A profile is **a pointer to a credentials file**. Nothing more. That constraint is what
|
|
10
|
+
* keeps the feature small: there is no mirror, no per-profile directory tree, no persistent
|
|
11
|
+
* "active profile" state to fall out of sync, and no migration.
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* ~/.config/neon/
|
|
15
|
+
* ├── credentials.json # this IS the DEFAULT profile, not a copy of it
|
|
16
|
+
* ├── credentials.work.json # created by `neon auth --profile work`
|
|
17
|
+
* └── profiles.json # created only once a second profile exists
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `profiles.json` maps a name to a path, and the path may point anywhere — which is what
|
|
21
|
+
* makes adopting an existing directory a one-line edit rather than an import command:
|
|
22
|
+
*
|
|
23
|
+
* ```json
|
|
24
|
+
* {
|
|
25
|
+
* "version": 1,
|
|
26
|
+
* "profiles": {
|
|
27
|
+
* "DEFAULT": { "credentials": "credentials.json" },
|
|
28
|
+
* "work": {
|
|
29
|
+
* "credentials": "../neonctl-databricks/credentials.json",
|
|
30
|
+
* "label": "someone@example.com"
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* ## Selection
|
|
37
|
+
*
|
|
38
|
+
* `--profile` → `NEON_PROFILE` → `DEFAULT`. Per invocation, like `AWS_PROFILE`; there is no
|
|
39
|
+
* `profile use` command, so nothing persists that could disagree with what you typed.
|
|
40
|
+
*
|
|
41
|
+
* ## Compatibility
|
|
42
|
+
*
|
|
43
|
+
* An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
|
|
44
|
+
* resolves to `credentials.json` in the config directory (including an existing one in the
|
|
45
|
+
* legacy `neonctl` directory — see `./paths.ts`). Nothing is created until a second
|
|
46
|
+
* profile is, and nothing is ever moved.
|
|
47
|
+
*/
|
|
48
|
+
const PROFILES_FILE = "profiles.json";
|
|
49
|
+
/** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
|
|
50
|
+
const DEFAULT_PROFILE = "DEFAULT";
|
|
51
|
+
/** Profile names become part of a filename, so keep them boring. */
|
|
52
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
53
|
+
/** Which profile this invocation should use: `--profile` → `NEON_PROFILE` → `DEFAULT`. */
|
|
54
|
+
const selectProfileName = (flag, env = process.env) => nonEmpty(flag) ?? nonEmpty(env.NEON_PROFILE) ?? "DEFAULT";
|
|
55
|
+
const assertValidProfileName = (name) => {
|
|
56
|
+
if (!NAME_PATTERN.test(name)) throw new Error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore, starting with a letter or digit.`);
|
|
57
|
+
};
|
|
58
|
+
/** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
|
|
59
|
+
const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
60
|
+
/**
|
|
61
|
+
* Read and classify `profiles.json` without deciding what to do about it.
|
|
62
|
+
*
|
|
63
|
+
* Entry keys and shapes are validated here rather than at each use. A key is a profile name,
|
|
64
|
+
* and a name that `assertValidProfileName` would reject cannot have been written by this CLI —
|
|
65
|
+
* it would travel into error messages as a recovery command nobody can run, and into a
|
|
66
|
+
* `credentials.<name>.json` filename.
|
|
67
|
+
*/
|
|
68
|
+
const inspectProfiles = (dir) => {
|
|
69
|
+
const path = profilesFilePath(dir);
|
|
70
|
+
if (!existsSync(path)) return { kind: "absent" };
|
|
71
|
+
const broken = (why) => ({
|
|
72
|
+
kind: "unusable",
|
|
73
|
+
reason: `${path} could not be read as a profiles file: ${why}`
|
|
74
|
+
});
|
|
75
|
+
let contents;
|
|
76
|
+
try {
|
|
77
|
+
contents = readFileSync(path, "utf8");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const code = err.code;
|
|
80
|
+
return broken(code ? `reading it failed with ${code}` : "reading it failed");
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(contents);
|
|
85
|
+
} catch {
|
|
86
|
+
return broken("it is not valid JSON");
|
|
87
|
+
}
|
|
88
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return broken("it does not contain an object");
|
|
89
|
+
const profiles = parsed.profiles;
|
|
90
|
+
if (profiles === null || typeof profiles !== "object" || Array.isArray(profiles)) return broken("it has no `profiles` object");
|
|
91
|
+
for (const [name, entry] of Object.entries(profiles)) {
|
|
92
|
+
if (!NAME_PATTERN.test(name)) return broken(`"${name}" is not a valid profile name`);
|
|
93
|
+
if (entry === null || typeof entry !== "object" || typeof entry.credentials !== "string" || entry.credentials.trim() === "") return broken(`profile "${name}" has no \`credentials\` path`);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
kind: "ok",
|
|
97
|
+
file: {
|
|
98
|
+
version: 1,
|
|
99
|
+
profiles
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Read `profiles.json`, or `null` when there is nothing usable there.
|
|
105
|
+
*
|
|
106
|
+
* A malformed file is reported through `onWarn` and treated as absent, because for a *read* the
|
|
107
|
+
* worst case is a named profile turning up missing, which is recoverable — whereas throwing
|
|
108
|
+
* would lock the user out of `neon auth` itself. Writing is the opposite: see
|
|
109
|
+
* {@link upsertProfile}, which refuses rather than rebuilding a file it cannot read.
|
|
110
|
+
*/
|
|
111
|
+
const readProfiles = (dir, onWarn = () => {}) => {
|
|
112
|
+
const read = inspectProfiles(dir);
|
|
113
|
+
if (read.kind === "ok") return read.file;
|
|
114
|
+
if (read.kind === "unusable") onWarn(read.reason);
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Refuse to act on a named profile when the file that defines it cannot be read.
|
|
119
|
+
*
|
|
120
|
+
* Call this **before** anything that writes a credential, opens a browser, or spends an API
|
|
121
|
+
* call. {@link upsertProfile} refuses too, but it runs last: by then `create` has already
|
|
122
|
+
* overwritten `credentials.<name>.json` and revoked the key it replaced, and `neon auth
|
|
123
|
+
* --profile` has already signed in over it — a refusal that arrives after the destruction it
|
|
124
|
+
* exists to prevent. The path resolution itself is the unsound part, since with the metadata
|
|
125
|
+
* unreadable the conventional filename is a guess about which account that file belongs to.
|
|
126
|
+
*
|
|
127
|
+
* `DEFAULT` is exempt: it is defined by the absence of metadata rather than by an entry, so
|
|
128
|
+
* signing in normally must keep working while a broken `profiles.json` is repaired.
|
|
129
|
+
*/
|
|
130
|
+
const assertProfilesUsable = (dir, name) => {
|
|
131
|
+
if (name === "DEFAULT") return;
|
|
132
|
+
const read = inspectProfiles(dir);
|
|
133
|
+
if (read.kind === "unusable") throw new Error(`${read.reason}. Fix or delete the file before working with profile "${name}" — it is the only record of where each account's credentials live.`);
|
|
134
|
+
};
|
|
135
|
+
/** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
|
|
136
|
+
const resolveProfile = (dir, name) => {
|
|
137
|
+
const read = inspectProfiles(dir);
|
|
138
|
+
if (read.kind === "unusable" && name !== "DEFAULT") throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
|
|
139
|
+
const file = read.kind === "ok" ? read.file : null;
|
|
140
|
+
const entry = file?.profiles[name];
|
|
141
|
+
if (entry) return {
|
|
142
|
+
name,
|
|
143
|
+
credentialsPath: resolveEntryPath(dir, entry.credentials),
|
|
144
|
+
...entry.label ? { label: entry.label } : {},
|
|
145
|
+
...entry.userId ? { userId: entry.userId } : {},
|
|
146
|
+
declared: true
|
|
147
|
+
};
|
|
148
|
+
if (name === "DEFAULT") return {
|
|
149
|
+
name,
|
|
150
|
+
credentialsPath: credentialsPath(dir),
|
|
151
|
+
declared: false
|
|
152
|
+
};
|
|
153
|
+
const known = file ? Object.keys(file.profiles).join(", ") : DEFAULT_PROFILE;
|
|
154
|
+
throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon profile create ${name}\`.`);
|
|
155
|
+
};
|
|
156
|
+
/** Default location for a new named profile's credentials file. */
|
|
157
|
+
const newProfileCredentialsPath = (dir, name) => resolve(dir, `credentials.${name}.json`);
|
|
158
|
+
/**
|
|
159
|
+
* Record a profile, creating `profiles.json` if this is the first named one.
|
|
160
|
+
*
|
|
161
|
+
* When the file is created, `DEFAULT` is written explicitly and pointed at wherever
|
|
162
|
+
* `credentials.json` actually is. That matters for an install predating the directory
|
|
163
|
+
* rename: `profiles.json` is created in `neon/` while the credentials are still in
|
|
164
|
+
* `neonctl/`, so `DEFAULT` is recorded as `../neonctl/credentials.json` rather than a
|
|
165
|
+
* relative name that would resolve to a file that isn't there.
|
|
166
|
+
*/
|
|
167
|
+
const upsertProfile = (dir, name, entry) => {
|
|
168
|
+
assertValidProfileName(name);
|
|
169
|
+
const path = profilesFilePath(dir);
|
|
170
|
+
const read = inspectProfiles(dir);
|
|
171
|
+
if (read.kind === "unusable") throw new Error(`${read.reason}. Refusing to rewrite it, because doing so would discard the profiles it defines. Fix or delete the file, then re-run.`);
|
|
172
|
+
const file = read.kind === "ok" ? read.file : {
|
|
173
|
+
version: 1,
|
|
174
|
+
profiles: { [DEFAULT_PROFILE]: { credentials: relativeToProfiles(path, credentialsPath(dir)) } }
|
|
175
|
+
};
|
|
176
|
+
file.profiles[name] = {
|
|
177
|
+
credentials: relativeToProfiles(path, entry.credentials),
|
|
178
|
+
...entry.label ? { label: entry.label } : {},
|
|
179
|
+
...entry.userId ? { userId: entry.userId } : {}
|
|
180
|
+
};
|
|
181
|
+
writeProfiles(path, file);
|
|
182
|
+
};
|
|
183
|
+
/** Remove an entry. Returns false when it wasn't there. */
|
|
184
|
+
const removeProfileEntry = (dir, name) => {
|
|
185
|
+
const path = profilesFilePath(dir);
|
|
186
|
+
const file = readProfiles(dir);
|
|
187
|
+
if (!file?.profiles[name]) return false;
|
|
188
|
+
delete file.profiles[name];
|
|
189
|
+
writeProfiles(path, file);
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* True when only `DEFAULT` is left, so `profiles.json` no longer earns its place. Mirrors
|
|
194
|
+
* lazy creation: a single-account install has no profiles file, before or after.
|
|
195
|
+
*/
|
|
196
|
+
const onlyDefaultRemains = (file) => {
|
|
197
|
+
const names = Object.keys(file.profiles);
|
|
198
|
+
return names.length === 0 || names.length === 1 && names[0] === "DEFAULT";
|
|
199
|
+
};
|
|
200
|
+
const listProfiles = (dir) => {
|
|
201
|
+
const read = inspectProfiles(dir);
|
|
202
|
+
if (read.kind === "unusable") throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
|
|
203
|
+
const file = read.kind === "ok" ? read.file : null;
|
|
204
|
+
if (!file) return [resolveProfile(dir, DEFAULT_PROFILE)];
|
|
205
|
+
const names = Object.keys(file.profiles);
|
|
206
|
+
if (!names.includes("DEFAULT")) names.unshift(DEFAULT_PROFILE);
|
|
207
|
+
return names.map((name) => resolveProfile(dir, name));
|
|
208
|
+
};
|
|
209
|
+
const writeProfiles = (path, file) => {
|
|
210
|
+
writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
211
|
+
};
|
|
212
|
+
const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
|
|
213
|
+
/** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
|
|
214
|
+
const profilesDir = (dir) => resolve(profilesFilePath(dir), "..");
|
|
215
|
+
/** Keep entries relative when they sit near `profiles.json`; absolute paths stay absolute. */
|
|
216
|
+
const relativeToProfiles = (profilesPath, target) => {
|
|
217
|
+
const rel = relative(resolve(profilesPath, ".."), target);
|
|
218
|
+
return rel && !isAbsolute(rel) ? rel : target;
|
|
219
|
+
};
|
|
220
|
+
function nonEmpty(value) {
|
|
221
|
+
if (typeof value !== "string") return void 0;
|
|
222
|
+
const trimmed = value.trim();
|
|
223
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
export { DEFAULT_PROFILE, PROFILES_FILE, assertProfilesUsable, assertValidProfileName, inspectProfiles, listProfiles, newProfileCredentialsPath, onlyDefaultRemains, profilesFilePath, readProfiles, removeProfileEntry, resolveProfile, selectProfileName, upsertProfile };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
//#region src/_shared/secure_file.ts
|
|
4
|
+
/** Owner read/write. A credential needs those two and nothing else. */
|
|
5
|
+
const SECRET_FILE_MODE = 384;
|
|
6
|
+
/**
|
|
7
|
+
* Write a secret to disk owner-only, by creating a temporary file in the same directory and
|
|
8
|
+
* renaming it over the target.
|
|
9
|
+
*
|
|
10
|
+
* The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
|
|
11
|
+
* applies only when it *creates* the file, so writing over an existing credentials file
|
|
12
|
+
* leaves whatever permissions it already had — a file created `0700` by an older release
|
|
13
|
+
* stays `0700` forever, and one created before a umask change stays world-readable. Renaming
|
|
14
|
+
* a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
|
|
15
|
+
* permissions repair themselves instead of being inherited.
|
|
16
|
+
*
|
|
17
|
+
* It also closes the window where a reader could see the file at default permissions: the
|
|
18
|
+
* temporary file is created `0600` *before* it holds the secret's final name, and `rename`
|
|
19
|
+
* is atomic within a directory, so there is no moment at which the target is readable by
|
|
20
|
+
* anyone else and no moment at which it is half-written.
|
|
21
|
+
*
|
|
22
|
+
* The temporary name carries the pid so two processes writing at once cannot collide on it.
|
|
23
|
+
*/
|
|
24
|
+
const writeSecretFile = (path, contents) => {
|
|
25
|
+
const directory = dirname(path);
|
|
26
|
+
const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(temporary, contents, {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
mode: 384
|
|
31
|
+
});
|
|
32
|
+
renameSync(temporary, path);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(temporary);
|
|
36
|
+
} catch {}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { SECRET_FILE_MODE, writeSecretFile };
|
package/dist/analytics.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { a as OAUTH, t as storeFor } from "./_chunks/credential_io-
|
|
1
|
+
import { a as OAUTH, t as storeFor } from "./_chunks/credential_io-D8AR-jvB.js";
|
|
2
2
|
import { getCliAgent, getGithubEnvVars, isCi } from "./env.js";
|
|
3
3
|
import { log } from "./log.js";
|
|
4
4
|
import pkg_default from "./pkg.js";
|
|
5
5
|
import { getApiClient, isNeonApiError } from "./api.js";
|
|
6
|
+
import { getAuthContext } from "./auth_context.js";
|
|
6
7
|
import { n as credentialsPath } from "./_chunks/paths-DMq0Lt7a.js";
|
|
7
8
|
import "./config.js";
|
|
8
|
-
import { getAuthContext } from "./auth_context.js";
|
|
9
9
|
import { isCurrentBranchProbe } from "./context.js";
|
|
10
10
|
import { Analytics } from "@segment/analytics-node";
|
|
11
11
|
//#region src/analytics.ts
|
package/dist/auth.js
CHANGED
|
@@ -30,13 +30,101 @@ const NEONCTL_SCOPES = [
|
|
|
30
30
|
];
|
|
31
31
|
const AUTH_TIMEOUT_SECONDS = 60;
|
|
32
32
|
const defaultClientID = "neonctl";
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/** `terminal` is a dead grant. A network failure is not. */
|
|
34
|
+
var AuthRefreshError = class extends Error {
|
|
35
|
+
constructor(message, options) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "AuthRefreshError";
|
|
38
|
+
this.terminal = options.terminal;
|
|
39
|
+
this.oauthError = options.oauthError;
|
|
40
|
+
this.cause = options.cause;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const DEAD_GRANT_ERRORS = /* @__PURE__ */ new Set([
|
|
44
|
+
"invalid_grant",
|
|
45
|
+
"token_inactive",
|
|
46
|
+
"invalid_token"
|
|
47
|
+
]);
|
|
48
|
+
const classifyRefreshFailure = (err) => {
|
|
49
|
+
const rejection = oauthRejection(err);
|
|
50
|
+
if (rejection) return new AuthRefreshError(`The Neon authorization server rejected the stored session: ${rejection.error}${rejection.description ? `: ${rejection.description}` : ""}`, {
|
|
51
|
+
terminal: DEAD_GRANT_ERRORS.has(rejection.error),
|
|
52
|
+
oauthError: rejection.error,
|
|
53
|
+
cause: err
|
|
54
|
+
});
|
|
55
|
+
return new AuthRefreshError(`Could not reach the Neon authorization server to refresh the stored session: ${err instanceof Error ? err.message : String(err)}`, {
|
|
56
|
+
terminal: false,
|
|
57
|
+
cause: err
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const oauthRejection = (err) => {
|
|
61
|
+
if (err instanceof client.ResponseBodyError) return {
|
|
62
|
+
error: err.error,
|
|
63
|
+
...err.error_description ? { description: err.error_description } : {}
|
|
64
|
+
};
|
|
65
|
+
if (err instanceof client.WWWAuthenticateChallengeError) {
|
|
66
|
+
const challenge = err.cause.find(({ parameters }) => parameters.error);
|
|
67
|
+
const error = challenge?.parameters.error;
|
|
68
|
+
if (typeof error !== "string") return null;
|
|
69
|
+
const description = challenge?.parameters.error_description;
|
|
70
|
+
return {
|
|
71
|
+
error,
|
|
72
|
+
...typeof description === "string" ? { description } : {}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
const isLoopbackHostname = (hostname) => hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
|
|
78
|
+
/** Remote HTTP would send the refresh token in the clear. Loopback is for local test servers. */
|
|
79
|
+
const oauthExecute = (oauthHost, allowUnsafeTls) => {
|
|
80
|
+
if (allowUnsafeTls === true) return [client.allowInsecureRequests];
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(oauthHost);
|
|
83
|
+
if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) return [client.allowInsecureRequests];
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const isSafeOAuthUrl = (value) => {
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(value);
|
|
91
|
+
if (url.protocol === "https:") return true;
|
|
92
|
+
return url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const assertSafeOAuthEndpoints = (configuration, allowUnsafeTls) => {
|
|
98
|
+
if (allowUnsafeTls === true) return;
|
|
99
|
+
const metadata = configuration.serverMetadata();
|
|
100
|
+
for (const endpoint of [
|
|
101
|
+
metadata.token_endpoint,
|
|
102
|
+
metadata.revocation_endpoint,
|
|
103
|
+
metadata.authorization_endpoint,
|
|
104
|
+
metadata.jwks_uri
|
|
105
|
+
]) {
|
|
106
|
+
if (typeof endpoint !== "string" || endpoint === "") continue;
|
|
107
|
+
if (!isSafeOAuthUrl(endpoint)) throw new AuthRefreshError(`The authorization server advertised ${endpoint}, which is not HTTPS or loopback HTTP.`, { terminal: false });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const discover = async ({ oauthHost, clientId, allowUnsafeTls }) => {
|
|
35
111
|
const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: "none" }, client.None(), {
|
|
36
112
|
timeout: SERVER_TIMEOUT,
|
|
37
|
-
execute: allowUnsafeTls
|
|
113
|
+
execute: oauthExecute(oauthHost, allowUnsafeTls)
|
|
38
114
|
});
|
|
39
|
-
|
|
115
|
+
assertSafeOAuthEndpoints(configuration, allowUnsafeTls);
|
|
116
|
+
return configuration;
|
|
117
|
+
};
|
|
118
|
+
const refreshToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) => {
|
|
119
|
+
const refresh = tokenSet.refresh_token;
|
|
120
|
+
if (typeof refresh !== "string" || refresh === "") throw new AuthRefreshError("The stored credentials hold no refresh token.", { terminal: true });
|
|
121
|
+
log.debug("Discovering oauth server");
|
|
122
|
+
const configuration = await discover({
|
|
123
|
+
oauthHost,
|
|
124
|
+
clientId,
|
|
125
|
+
allowUnsafeTls
|
|
126
|
+
});
|
|
127
|
+
return await client.refreshTokenGrant(configuration, refresh);
|
|
40
128
|
};
|
|
41
129
|
/**
|
|
42
130
|
* Invalidate a refresh token at the authorization server (RFC 7009).
|
|
@@ -50,9 +138,10 @@ const revokeToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) =>
|
|
|
50
138
|
const token = tokenSet.refresh_token;
|
|
51
139
|
if (typeof token !== "string" || token === "") return false;
|
|
52
140
|
try {
|
|
53
|
-
const configuration = await
|
|
54
|
-
|
|
55
|
-
|
|
141
|
+
const configuration = await discover({
|
|
142
|
+
oauthHost,
|
|
143
|
+
clientId,
|
|
144
|
+
allowUnsafeTls
|
|
56
145
|
});
|
|
57
146
|
await client.tokenRevocation(configuration, token, { token_type_hint: "refresh_token" });
|
|
58
147
|
return true;
|
|
@@ -63,9 +152,10 @@ const revokeToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) =>
|
|
|
63
152
|
};
|
|
64
153
|
const auth = async ({ oauthHost, clientId, allowUnsafeTls }) => {
|
|
65
154
|
log.debug("Discovering oauth server");
|
|
66
|
-
const configuration = await
|
|
67
|
-
|
|
68
|
-
|
|
155
|
+
const configuration = await discover({
|
|
156
|
+
oauthHost,
|
|
157
|
+
clientId,
|
|
158
|
+
allowUnsafeTls
|
|
69
159
|
});
|
|
70
160
|
log.debug("Starting HTTP Server for callback");
|
|
71
161
|
const server = createServer();
|
|
@@ -131,4 +221,4 @@ const auth = async ({ oauthHost, clientId, allowUnsafeTls }) => {
|
|
|
131
221
|
});
|
|
132
222
|
};
|
|
133
223
|
//#endregion
|
|
134
|
-
export { auth, defaultClientID, refreshToken, revokeToken };
|
|
224
|
+
export { AuthRefreshError, auth, classifyRefreshFailure, defaultClientID, refreshToken, revokeToken };
|
package/dist/auth_context.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { a as isOwnedCredentialPath } from "./_chunks/paths-DMq0Lt7a.js";
|
|
2
|
-
import "./config.js";
|
|
3
1
|
//#region src/auth_context.ts
|
|
4
2
|
const locationFromContext = (context) => {
|
|
5
3
|
if (context.profile === void 0) return null;
|
|
@@ -23,13 +21,6 @@ const getAuthContext = () => current;
|
|
|
23
21
|
const clearAuthContext = () => {
|
|
24
22
|
current = null;
|
|
25
23
|
};
|
|
26
|
-
const credentialsToClearOn401 = (context) => {
|
|
27
|
-
if (context?.source !== "stored-credentials") return null;
|
|
28
|
-
const at = locationFromContext(context);
|
|
29
|
-
if (at === null) return null;
|
|
30
|
-
if (at.storage === "keyring") return null;
|
|
31
|
-
return isOwnedCredentialPath(context.configDir, at.path) ? at : null;
|
|
32
|
-
};
|
|
33
24
|
/**
|
|
34
25
|
* What to tell the user when the API rejects their credential, naming the profile and file
|
|
35
26
|
* when one is involved so they know which of several accounts failed and what to re-run.
|
|
@@ -41,9 +32,9 @@ const authFailureMessage = (context) => {
|
|
|
41
32
|
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\`.`;
|
|
42
33
|
if (context?.source === "stored-credentials") {
|
|
43
34
|
if (context.storage === "keyring") return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session (OS keyring). Sign in again with \`neon auth --profile ${profile}\`.`;
|
|
44
|
-
return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session${where}.
|
|
35
|
+
return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session${where}. Sign in again with \`neon auth --profile ${profile}\`.`;
|
|
45
36
|
}
|
|
46
37
|
return "Authentication failed: the Neon API rejected the API key. Check --api-key or NEON_API_KEY.";
|
|
47
38
|
};
|
|
48
39
|
//#endregion
|
|
49
|
-
export { authFailureMessage, clearAuthContext,
|
|
40
|
+
export { authFailureMessage, clearAuthContext, getAuthContext, locationFromContext, setAuthContext };
|
package/dist/claimable/api.js
CHANGED
package/dist/commands/ask.js
CHANGED
|
@@ -128,7 +128,8 @@ async function askAssistant(opts) {
|
|
|
128
128
|
method: "POST",
|
|
129
129
|
headers: {
|
|
130
130
|
"Content-Type": "application/json",
|
|
131
|
-
Accept: opts.acceptEventStream ? "text/event-stream" : "application/json"
|
|
131
|
+
Accept: opts.acceptEventStream ? "text/event-stream" : "application/json",
|
|
132
|
+
"x-neon-source": "cli"
|
|
132
133
|
},
|
|
133
134
|
body: JSON.stringify({ prompt: opts.prompt }),
|
|
134
135
|
signal: AbortSignal.timeout(ASK_TIMEOUT_MS)
|