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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # Where the Neon CLIs keep their files on disk
|
|
3
|
+
*
|
|
4
|
+
**Deliberately impure.** It reads environment variables and touches the filesystem, which
|
|
5
|
+
* `@neon/config` — the package this used to be a subpath of — must never do from its root
|
|
6
|
+
* export. It lives here instead of there precisely so that a policy-facing package does not
|
|
7
|
+
* carry implementor-only code, and so `neon-init`, which has no workspace dependencies, can use
|
|
8
|
+
* the same resolution as everything else.
|
|
9
|
+
*
|
|
10
|
+
* It exists because three separate readers each grew their own answer to "where is the
|
|
11
|
+
* config directory", and all three disagreed: `packages/cli` honoured `XDG_CONFIG_HOME` but
|
|
12
|
+
* not `NEONCTL_CONFIG_DIR`, `packages/env` honoured the env var but not XDG, and
|
|
13
|
+
* `packages/init` hardcoded `~/.config/neonctl`. With `XDG_CONFIG_HOME` set, the CLI wrote
|
|
14
|
+
* credentials somewhere the other two never looked.
|
|
15
|
+
*
|
|
16
|
+
* ## The directory
|
|
17
|
+
*
|
|
18
|
+
* `neon` is the current name; `neonctl` is the legacy one, kept readable forever. Resolution,
|
|
19
|
+
* each entry winning over the next:
|
|
20
|
+
*
|
|
21
|
+
* 1. An explicit directory (a `--config-dir` flag) — **exact**, no legacy fallback.
|
|
22
|
+
* 2. `NEON_CONFIG_DIR` — exact.
|
|
23
|
+
* 3. `NEONCTL_CONFIG_DIR` (legacy name) — exact.
|
|
24
|
+
* 4. `$XDG_CONFIG_HOME/neon`, else `<home>/.config/neon`.
|
|
25
|
+
*
|
|
26
|
+
* An explicitly chosen directory is never paired with a fallback: `--config-dir /tmp/ci` that
|
|
27
|
+
* quietly read `~/.config/neonctl` would defeat the point of passing it.
|
|
28
|
+
*
|
|
29
|
+
* ## The files
|
|
30
|
+
*
|
|
31
|
+
* {@link resolveConfigFile} answers "which path should I use for this file", and it is the
|
|
32
|
+
* same answer for reading and writing:
|
|
33
|
+
*
|
|
34
|
+
* - Present in `neon/` → use it.
|
|
35
|
+
* - Present only in `neonctl/` → **use it there, in place.** An existing credentials file is
|
|
36
|
+
* never copied or moved, so nothing is left behind to go stale and no other tool starts
|
|
37
|
+
* reading an abandoned token.
|
|
38
|
+
* - Present in neither → the new location. New files only ever appear under `neon/`.
|
|
39
|
+
*/
|
|
40
|
+
import { existsSync } from "node:fs";
|
|
41
|
+
import { join, resolve } from "node:path";
|
|
42
|
+
/** Current directory name. New files are created here. */
|
|
43
|
+
export const CONFIG_DIR_NAME = "neon";
|
|
44
|
+
/** Legacy directory name, read forever so existing installs keep working untouched. */
|
|
45
|
+
export const LEGACY_CONFIG_DIR_NAME = "neonctl";
|
|
46
|
+
/** Where files are created. See the module docs for the precedence. */
|
|
47
|
+
export function configDir(options = {}) {
|
|
48
|
+
const explicit = explicitDir(options);
|
|
49
|
+
if (explicit)
|
|
50
|
+
return explicit;
|
|
51
|
+
return join(configHome(options.env ?? process.env), CONFIG_DIR_NAME);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The legacy directory, or `undefined` when the location was chosen explicitly (in which
|
|
55
|
+
* case there is no legacy counterpart to fall back to).
|
|
56
|
+
*/
|
|
57
|
+
export function legacyConfigDir(options = {}) {
|
|
58
|
+
if (explicitDir(options))
|
|
59
|
+
return undefined;
|
|
60
|
+
return join(configHome(options.env ?? process.env), LEGACY_CONFIG_DIR_NAME);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve one file inside the config directory. Prefers the current location, falls back to
|
|
64
|
+
* an existing legacy file **in place**, and otherwise points at the current location so new
|
|
65
|
+
* files are created there.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveConfigFile(fileName, options = {}) {
|
|
68
|
+
const dir = configDir(options);
|
|
69
|
+
const current = resolve(dir, fileName);
|
|
70
|
+
if (existsSync(current))
|
|
71
|
+
return { path: current, dir, isLegacy: false, exists: true };
|
|
72
|
+
const legacyDir = legacyConfigDir(options);
|
|
73
|
+
if (legacyDir) {
|
|
74
|
+
const legacy = resolve(legacyDir, fileName);
|
|
75
|
+
if (existsSync(legacy))
|
|
76
|
+
return {
|
|
77
|
+
path: legacy,
|
|
78
|
+
dir: legacyDir,
|
|
79
|
+
isLegacy: true,
|
|
80
|
+
exists: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { path: current, dir, isLegacy: false, exists: false };
|
|
84
|
+
}
|
|
85
|
+
/** `$XDG_CONFIG_HOME`, else `<home>/.config`. Falls back to a relative `.config` with no home. */
|
|
86
|
+
function configHome(env) {
|
|
87
|
+
const xdg = nonEmpty(env.XDG_CONFIG_HOME);
|
|
88
|
+
if (xdg)
|
|
89
|
+
return xdg;
|
|
90
|
+
const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
|
|
91
|
+
return home ? join(home, ".config") : ".config";
|
|
92
|
+
}
|
|
93
|
+
function explicitDir(options) {
|
|
94
|
+
const env = options.env ?? process.env;
|
|
95
|
+
return (nonEmpty(options.dir) ??
|
|
96
|
+
nonEmpty(env.NEON_CONFIG_DIR) ??
|
|
97
|
+
nonEmpty(env.NEONCTL_CONFIG_DIR));
|
|
98
|
+
}
|
|
99
|
+
function nonEmpty(value) {
|
|
100
|
+
if (typeof value !== "string")
|
|
101
|
+
return undefined;
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
return trimmed === "" ? undefined : trimmed;
|
|
104
|
+
}
|
|
105
|
+
export const CREDENTIALS_FILE = "credentials.json";
|
|
106
|
+
/**
|
|
107
|
+
* Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
|
|
108
|
+
*
|
|
109
|
+
* The directory was called `neonctl` until the CLI was renamed. An existing one is still read —
|
|
110
|
+
* see {@link credentialsPath} — but it is never written to, moved, or deleted.
|
|
111
|
+
*/
|
|
112
|
+
export const defaultDir = configDir();
|
|
113
|
+
/**
|
|
114
|
+
* Where this invocation's `credentials.json` lives.
|
|
115
|
+
*
|
|
116
|
+
* When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
|
|
117
|
+
* directory is used **in place**: an install that predates the rename keeps working, and its
|
|
118
|
+
* credentials are never duplicated into a second location where one copy could go stale while
|
|
119
|
+
* another tool still reads it.
|
|
120
|
+
*
|
|
121
|
+
* A `--config-dir` the user actually passed is used exactly as given. Falling back out of an
|
|
122
|
+
* explicitly chosen directory would defeat the reason for choosing it — a CI run pointed at a
|
|
123
|
+
* scratch directory must never pick up a developer's real credentials.
|
|
124
|
+
*/
|
|
125
|
+
export const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
126
|
+
/**
|
|
127
|
+
* Whether a credentials file is one the CLI created, rather than a path a profile adopted.
|
|
128
|
+
*
|
|
129
|
+
* Anything that deletes a credential has to ask this first. A profile entry may point anywhere —
|
|
130
|
+
* that is what makes adopting an existing directory a one-line edit — and a file we did not
|
|
131
|
+
* create is not ours to remove.
|
|
132
|
+
*/
|
|
133
|
+
export const isInsideConfigDir = (configDirectory, file) => `${resolve(file)}/`.startsWith(`${resolve(configDirectory)}/`);
|
|
134
|
+
/**
|
|
135
|
+
* Whether a credentials file is one the CLI owns, counting the legacy `neonctl` directory.
|
|
136
|
+
*
|
|
137
|
+
* {@link credentialsPath} deliberately reads an existing legacy file in place rather than
|
|
138
|
+
* migrating it, so for a default config directory that file is ours even though it sits outside
|
|
139
|
+
* `neon/`. Judging ownership on the current directory alone would call an install that predates
|
|
140
|
+
* the rename "adopted".
|
|
141
|
+
*/
|
|
142
|
+
export const isOwnedCredentialPath = (configDirectory, file) => {
|
|
143
|
+
if (isInsideConfigDir(configDirectory, file))
|
|
144
|
+
return true;
|
|
145
|
+
if (configDirectory !== defaultDir)
|
|
146
|
+
return false;
|
|
147
|
+
const legacy = legacyConfigDir();
|
|
148
|
+
return legacy !== undefined && isInsideConfigDir(legacy, file);
|
|
149
|
+
};
|
|
@@ -37,14 +37,13 @@
|
|
|
37
37
|
*
|
|
38
38
|
* An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
|
|
39
39
|
* resolves to `credentials.json` in the config directory (including an existing one in the
|
|
40
|
-
* legacy `neonctl` directory — see
|
|
40
|
+
* legacy `neonctl` directory — see `./paths.ts`). Nothing is created until a second
|
|
41
41
|
* profile is, and nothing is ever moved.
|
|
42
42
|
*/
|
|
43
|
-
import { existsSync, readFileSync
|
|
43
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
44
44
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
45
|
-
import { resolveConfigFile } from "
|
|
46
|
-
import {
|
|
47
|
-
import { log } from "./log.js";
|
|
45
|
+
import { credentialsPath, defaultDir, resolveConfigFile } from "./paths.js";
|
|
46
|
+
import { writeSecretFile } from "./secure_file.js";
|
|
48
47
|
export const PROFILES_FILE = "profiles.json";
|
|
49
48
|
/** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
|
|
50
49
|
export const DEFAULT_PROFILE = "DEFAULT";
|
|
@@ -60,36 +59,106 @@ export const assertValidProfileName = (name) => {
|
|
|
60
59
|
/** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
|
|
61
60
|
export const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
62
61
|
/**
|
|
63
|
-
* Read `profiles.json
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
62
|
+
* Read and classify `profiles.json` without deciding what to do about it.
|
|
63
|
+
*
|
|
64
|
+
* Entry keys and shapes are validated here rather than at each use. A key is a profile name,
|
|
65
|
+
* and a name that `assertValidProfileName` would reject cannot have been written by this CLI —
|
|
66
|
+
* it would travel into error messages as a recovery command nobody can run, and into a
|
|
67
|
+
* `credentials.<name>.json` filename.
|
|
67
68
|
*/
|
|
68
|
-
export const
|
|
69
|
+
export const inspectProfiles = (dir) => {
|
|
69
70
|
const path = profilesFilePath(dir);
|
|
70
71
|
if (!existsSync(path))
|
|
71
|
-
return
|
|
72
|
+
return { kind: "absent" };
|
|
73
|
+
const broken = (why) => ({
|
|
74
|
+
kind: "unusable",
|
|
75
|
+
reason: `${path} could not be read as a profiles file: ${why}`,
|
|
76
|
+
});
|
|
77
|
+
// Reading and parsing are separate failures with separate answers. Sharing one catch
|
|
78
|
+
// reported `EACCES` as "not valid JSON", which sends the user to edit a file that is
|
|
79
|
+
// perfectly valid and that they cannot open.
|
|
80
|
+
let contents;
|
|
72
81
|
try {
|
|
73
|
-
|
|
74
|
-
if (parsed === null ||
|
|
75
|
-
typeof parsed !== "object" ||
|
|
76
|
-
Array.isArray(parsed))
|
|
77
|
-
throw new Error("not an object");
|
|
78
|
-
const profiles = parsed.profiles;
|
|
79
|
-
if (profiles === null ||
|
|
80
|
-
typeof profiles !== "object" ||
|
|
81
|
-
Array.isArray(profiles))
|
|
82
|
-
throw new Error("missing `profiles`");
|
|
83
|
-
return { version: 1, profiles };
|
|
82
|
+
contents = readFileSync(path, "utf8");
|
|
84
83
|
}
|
|
85
84
|
catch (err) {
|
|
86
|
-
|
|
87
|
-
return
|
|
85
|
+
const code = err.code;
|
|
86
|
+
return broken(code ? `reading it failed with ${code}` : "reading it failed");
|
|
87
|
+
}
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(contents);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return broken("it is not valid JSON");
|
|
94
|
+
}
|
|
95
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
96
|
+
return broken("it does not contain an object");
|
|
97
|
+
const profiles = parsed.profiles;
|
|
98
|
+
if (profiles === null ||
|
|
99
|
+
typeof profiles !== "object" ||
|
|
100
|
+
Array.isArray(profiles))
|
|
101
|
+
return broken("it has no `profiles` object");
|
|
102
|
+
for (const [name, entry] of Object.entries(profiles)) {
|
|
103
|
+
if (!NAME_PATTERN.test(name))
|
|
104
|
+
return broken(`"${name}" is not a valid profile name`);
|
|
105
|
+
if (entry === null ||
|
|
106
|
+
typeof entry !== "object" ||
|
|
107
|
+
typeof entry.credentials !== "string" ||
|
|
108
|
+
entry.credentials.trim() === "") {
|
|
109
|
+
return broken(`profile "${name}" has no \`credentials\` path`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { kind: "ok", file: { version: 1, profiles } };
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Read `profiles.json`, or `null` when there is nothing usable there.
|
|
116
|
+
*
|
|
117
|
+
* A malformed file is reported through `onWarn` and treated as absent, because for a *read* the
|
|
118
|
+
* worst case is a named profile turning up missing, which is recoverable — whereas throwing
|
|
119
|
+
* would lock the user out of `neon auth` itself. Writing is the opposite: see
|
|
120
|
+
* {@link upsertProfile}, which refuses rather than rebuilding a file it cannot read.
|
|
121
|
+
*/
|
|
122
|
+
export const readProfiles = (dir,
|
|
123
|
+
/** Called with the reason a profiles file was ignored. The consumer owns how it reports. */
|
|
124
|
+
onWarn = () => { }) => {
|
|
125
|
+
const read = inspectProfiles(dir);
|
|
126
|
+
if (read.kind === "ok")
|
|
127
|
+
return read.file;
|
|
128
|
+
if (read.kind === "unusable")
|
|
129
|
+
onWarn(read.reason);
|
|
130
|
+
return null;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Refuse to act on a named profile when the file that defines it cannot be read.
|
|
134
|
+
*
|
|
135
|
+
* Call this **before** anything that writes a credential, opens a browser, or spends an API
|
|
136
|
+
* call. {@link upsertProfile} refuses too, but it runs last: by then `create` has already
|
|
137
|
+
* overwritten `credentials.<name>.json` and revoked the key it replaced, and `neon auth
|
|
138
|
+
* --profile` has already signed in over it — a refusal that arrives after the destruction it
|
|
139
|
+
* exists to prevent. The path resolution itself is the unsound part, since with the metadata
|
|
140
|
+
* unreadable the conventional filename is a guess about which account that file belongs to.
|
|
141
|
+
*
|
|
142
|
+
* `DEFAULT` is exempt: it is defined by the absence of metadata rather than by an entry, so
|
|
143
|
+
* signing in normally must keep working while a broken `profiles.json` is repaired.
|
|
144
|
+
*/
|
|
145
|
+
export const assertProfilesUsable = (dir, name) => {
|
|
146
|
+
if (name === DEFAULT_PROFILE)
|
|
147
|
+
return;
|
|
148
|
+
const read = inspectProfiles(dir);
|
|
149
|
+
if (read.kind === "unusable") {
|
|
150
|
+
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.`);
|
|
88
151
|
}
|
|
89
152
|
};
|
|
90
153
|
/** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
|
|
91
154
|
export const resolveProfile = (dir, name) => {
|
|
92
|
-
const
|
|
155
|
+
const read = inspectProfiles(dir);
|
|
156
|
+
// A broken file must not be reported as `Unknown profile "work"`. That names the wrong
|
|
157
|
+
// problem, and the user goes looking for a profile they can see in the file in front of them.
|
|
158
|
+
if (read.kind === "unusable" && name !== DEFAULT_PROFILE) {
|
|
159
|
+
throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
|
|
160
|
+
}
|
|
161
|
+
const file = read.kind === "ok" ? read.file : null;
|
|
93
162
|
const entry = file?.profiles[name];
|
|
94
163
|
if (entry) {
|
|
95
164
|
return {
|
|
@@ -112,7 +181,7 @@ export const resolveProfile = (dir, name) => {
|
|
|
112
181
|
const known = file
|
|
113
182
|
? Object.keys(file.profiles).join(", ")
|
|
114
183
|
: DEFAULT_PROFILE;
|
|
115
|
-
throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon
|
|
184
|
+
throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon profile create ${name}\`.`);
|
|
116
185
|
};
|
|
117
186
|
/** Default location for a new named profile's credentials file. */
|
|
118
187
|
export const newProfileCredentialsPath = (dir, name) => resolve(dir, `credentials.${name}.json`);
|
|
@@ -128,14 +197,24 @@ export const newProfileCredentialsPath = (dir, name) => resolve(dir, `credential
|
|
|
128
197
|
export const upsertProfile = (dir, name, entry) => {
|
|
129
198
|
assertValidProfileName(name);
|
|
130
199
|
const path = profilesFilePath(dir);
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
200
|
+
const read = inspectProfiles(dir);
|
|
201
|
+
// Refusing is the point. Treating a broken file as absent here rebuilt it from a single
|
|
202
|
+
// `DEFAULT` entry and dropped every named profile in it — silent data loss, in the file
|
|
203
|
+
// that is the only record of where each account's credentials live. The credentials
|
|
204
|
+
// themselves survive, so fixing the file by hand recovers everything.
|
|
205
|
+
if (read.kind === "unusable") {
|
|
206
|
+
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.`);
|
|
207
|
+
}
|
|
208
|
+
const file = read.kind === "ok"
|
|
209
|
+
? read.file
|
|
210
|
+
: {
|
|
211
|
+
version: 1,
|
|
212
|
+
profiles: {
|
|
213
|
+
[DEFAULT_PROFILE]: {
|
|
214
|
+
credentials: relativeToProfiles(path, credentialsPath(dir)),
|
|
215
|
+
},
|
|
136
216
|
},
|
|
137
|
-
}
|
|
138
|
-
};
|
|
217
|
+
};
|
|
139
218
|
file.profiles[name] = {
|
|
140
219
|
credentials: relativeToProfiles(path, entry.credentials),
|
|
141
220
|
...(entry.label ? { label: entry.label } : {}),
|
|
@@ -163,7 +242,14 @@ export const onlyDefaultRemains = (file) => {
|
|
|
163
242
|
(names.length === 1 && names[0] === DEFAULT_PROFILE));
|
|
164
243
|
};
|
|
165
244
|
export const listProfiles = (dir) => {
|
|
166
|
-
const
|
|
245
|
+
const read = inspectProfiles(dir);
|
|
246
|
+
// Listing is the command run to find out what is there, so a broken file is the answer
|
|
247
|
+
// rather than an obstacle. Showing only `DEFAULT` would state, as fact, that the profiles
|
|
248
|
+
// in that file do not exist.
|
|
249
|
+
if (read.kind === "unusable") {
|
|
250
|
+
throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
|
|
251
|
+
}
|
|
252
|
+
const file = read.kind === "ok" ? read.file : null;
|
|
167
253
|
if (!file)
|
|
168
254
|
return [resolveProfile(dir, DEFAULT_PROFILE)];
|
|
169
255
|
const names = Object.keys(file.profiles);
|
|
@@ -172,7 +258,7 @@ export const listProfiles = (dir) => {
|
|
|
172
258
|
return names.map((name) => resolveProfile(dir, name));
|
|
173
259
|
};
|
|
174
260
|
const writeProfiles = (path, file) => {
|
|
175
|
-
|
|
261
|
+
writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
176
262
|
};
|
|
177
263
|
const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
|
|
178
264
|
/** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
/** Owner read/write. A credential needs those two and nothing else. */
|
|
4
|
+
export const SECRET_FILE_MODE = 0o600;
|
|
5
|
+
/**
|
|
6
|
+
* Write a secret to disk owner-only, by creating a temporary file in the same directory and
|
|
7
|
+
* renaming it over the target.
|
|
8
|
+
*
|
|
9
|
+
* The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
|
|
10
|
+
* applies only when it *creates* the file, so writing over an existing credentials file
|
|
11
|
+
* leaves whatever permissions it already had — a file created `0700` by an older release
|
|
12
|
+
* stays `0700` forever, and one created before a umask change stays world-readable. Renaming
|
|
13
|
+
* a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
|
|
14
|
+
* permissions repair themselves instead of being inherited.
|
|
15
|
+
*
|
|
16
|
+
* It also closes the window where a reader could see the file at default permissions: the
|
|
17
|
+
* temporary file is created `0600` *before* it holds the secret's final name, and `rename`
|
|
18
|
+
* is atomic within a directory, so there is no moment at which the target is readable by
|
|
19
|
+
* anyone else and no moment at which it is half-written.
|
|
20
|
+
*
|
|
21
|
+
* The temporary name carries the pid so two processes writing at once cannot collide on it.
|
|
22
|
+
*/
|
|
23
|
+
export const writeSecretFile = (path, contents) => {
|
|
24
|
+
const directory = dirname(path);
|
|
25
|
+
const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(temporary, contents, {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
mode: SECRET_FILE_MODE,
|
|
30
|
+
});
|
|
31
|
+
renameSync(temporary, path);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Never leave the secret behind under a temporary name the caller doesn't know about.
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(temporary);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// The temp file was never created, or is already gone. Report the original error.
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
};
|
package/dist/analytics.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import { Analytics } from "@segment/analytics-node";
|
|
2
|
+
import { inspectCredentials } from "./_shared/credentials.js";
|
|
3
3
|
import { getApiClient, isNeonApiError } from "./api.js";
|
|
4
|
+
import { getAuthContext } from "./auth_context.js";
|
|
4
5
|
import { credentialsPath } from "./config.js";
|
|
5
6
|
import { isCurrentBranchProbe } from "./context.js";
|
|
6
7
|
import { getGithubEnvVars, isCi } from "./env.js";
|
|
@@ -55,14 +56,23 @@ export const analyticsMiddleware = async (args) => {
|
|
|
55
56
|
if (isCurrentBranchProbe(args)) {
|
|
56
57
|
return;
|
|
57
58
|
}
|
|
59
|
+
// Read the credentials this invocation actually authenticated with, which `ensureAuth`
|
|
60
|
+
// recorded. Reading `DEFAULT`'s unconditionally attributed every `--profile`-selected
|
|
61
|
+
// command to whichever account happened to be the default one.
|
|
62
|
+
const authenticatedAs = getAuthContext()?.credentialsPath ?? credentialsPath(args.configDir);
|
|
63
|
+
// Telemetry must never turn a damaged or unreadable credentials file into a failed command.
|
|
58
64
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const read = inspectCredentials(authenticatedAs);
|
|
66
|
+
if (read.kind === "ok" &&
|
|
67
|
+
typeof read.credentials.user_id === "string") {
|
|
68
|
+
userId = read.credentials.user_id;
|
|
69
|
+
}
|
|
70
|
+
else if (read.kind !== "ok") {
|
|
71
|
+
log.debug("No usable credentials at %s", authenticatedAs);
|
|
72
|
+
}
|
|
63
73
|
}
|
|
64
74
|
catch (err) {
|
|
65
|
-
log.debug("
|
|
75
|
+
log.debug("Could not read %s: %s", authenticatedAs, err);
|
|
66
76
|
}
|
|
67
77
|
try {
|
|
68
78
|
if (args.apiKey) {
|
package/dist/api.js
CHANGED
|
@@ -111,9 +111,44 @@ function headersToObject(headers) {
|
|
|
111
111
|
});
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Raised by {@link makeTimedFetch} when the CLI's own request timeout fires. Owning the
|
|
116
|
+
* type is what makes the timeout recognisable further up: `@neon/sdk` reports every
|
|
117
|
+
* transport failure as a `NeonNetworkError`, so matching on names or codes cannot tell a
|
|
118
|
+
* timeout from a reset connection.
|
|
119
|
+
*/
|
|
120
|
+
class RequestTimeoutError extends Error {
|
|
121
|
+
constructor(timeoutMs, reason) {
|
|
122
|
+
super(`Request timed out after ${timeoutMs}ms`);
|
|
123
|
+
this.name = "RequestTimeoutError";
|
|
124
|
+
this.reason = reason;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a failure was a request timeout rather than a connectivity problem.
|
|
129
|
+
*
|
|
130
|
+
* Walks the `cause` chain, because the SDK wraps whatever `fetch` threw. Before this, the
|
|
131
|
+
* check only looked at the top-level error's `name` — which is `NeonNetworkError` on every
|
|
132
|
+
* SDK path — so a timeout fell through to the connectivity branch and the user was told to
|
|
133
|
+
* check an internet connection that was working.
|
|
134
|
+
*/
|
|
114
135
|
function isAbortError(err) {
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
let current = err;
|
|
137
|
+
for (let depth = 0; depth < 6 && current != null; depth++) {
|
|
138
|
+
if (current instanceof RequestTimeoutError)
|
|
139
|
+
return true;
|
|
140
|
+
if (current instanceof Error &&
|
|
141
|
+
(current.name === "AbortError" || current.name === "TimeoutError")) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
// `@neon/sdk` classifies its own deadlines and cancellations by kind; the CLI
|
|
145
|
+
// does not set them today, but reading them keeps this correct if it ever does.
|
|
146
|
+
const kind = current.kind;
|
|
147
|
+
if (kind === "timeout" || kind === "aborted")
|
|
148
|
+
return true;
|
|
149
|
+
current = current.cause;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
117
152
|
}
|
|
118
153
|
/**
|
|
119
154
|
* Walk an error's `cause` chain to find the underlying socket/DNS `code` (e.g.
|
|
@@ -182,17 +217,53 @@ function networkError(err) {
|
|
|
182
217
|
* and lightweight debug logging of the request line + response status — the
|
|
183
218
|
* fetch-native replacement for the old `axios-debug-log` wiring.
|
|
184
219
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
220
|
+
/**
|
|
221
|
+
* The largest delay a timer can represent. Above it Node warns
|
|
222
|
+
* (`TimeoutOverflowWarning`) and fires after 1ms instead.
|
|
223
|
+
*/
|
|
224
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
225
|
+
/**
|
|
226
|
+
* Reject a timeout `AbortSignal.timeout` would refuse or silently mistreat.
|
|
227
|
+
*
|
|
228
|
+
* Without this the bad value surfaces as the very failure this classification exists to
|
|
229
|
+
* prevent: `-1`, `NaN`, `Infinity`, fractions and anything above `4294967295` throw
|
|
230
|
+
* `ERR_OUT_OF_RANGE` from inside the fetch wrapper, which is then wrapped as a
|
|
231
|
+
* `NeonNetworkError` and reported as a broken internet connection.
|
|
232
|
+
*
|
|
233
|
+
* `0`, and the band from {@link MAX_TIMER_MS} + 1 up to `4294967295`, are worse still:
|
|
234
|
+
* both are accepted, and both make every request time out immediately — `0` by asking for
|
|
235
|
+
* it, the band because a timer above the signed 32-bit ceiling collapses to 1ms.
|
|
236
|
+
*/
|
|
237
|
+
function validateRequestTimeout(ms) {
|
|
238
|
+
if (!Number.isInteger(ms) || ms < 1 || ms > MAX_TIMER_MS) {
|
|
239
|
+
throw new Error(`requestTimeoutMs must be a whole number of milliseconds between 1 and ${MAX_TIMER_MS}; received ${ms}.`);
|
|
240
|
+
}
|
|
241
|
+
return ms;
|
|
242
|
+
}
|
|
243
|
+
const makeTimedFetch = (requestTimeoutMs) => async (input, init) => {
|
|
244
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
187
245
|
const signal = init?.signal
|
|
188
246
|
? AbortSignal.any([init.signal, timeout])
|
|
189
247
|
: timeout;
|
|
190
248
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
191
249
|
const url = input instanceof Request ? input.url : String(input);
|
|
192
250
|
log.debug("%s %s", method.toUpperCase(), url);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(input, { ...init, signal });
|
|
253
|
+
log.debug("%d %s", response.status, response.statusText);
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
// Our own timeout fired, and we are the only code that knows that: by the
|
|
258
|
+
// time this reaches `networkError` the SDK has wrapped it as a
|
|
259
|
+
// `NeonNetworkError`, and neither its name nor its `cause` chain carries a
|
|
260
|
+
// string code to recognise. Raise something we own instead of leaving the
|
|
261
|
+
// classification to guess.
|
|
262
|
+
if (timeout.aborted && !init?.signal?.aborted) {
|
|
263
|
+
throw new RequestTimeoutError(requestTimeoutMs, err);
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
196
267
|
};
|
|
197
268
|
const RETRY_COUNT = 5;
|
|
198
269
|
const RETRY_DELAY = 3000;
|
|
@@ -246,12 +317,15 @@ async function readJsonBody(response) {
|
|
|
246
317
|
return text;
|
|
247
318
|
}
|
|
248
319
|
}
|
|
249
|
-
export const getApiClient = ({ apiKey, apiHost }) => {
|
|
320
|
+
export const getApiClient = ({ apiKey, apiHost, requestTimeoutMs = REQUEST_TIMEOUT_MS, }) => {
|
|
250
321
|
const baseUrl = apiHost ?? DEFAULT_API_HOST;
|
|
322
|
+
// Shared by the generated client and the low-level `request()` escape hatch, so both
|
|
323
|
+
// paths get the same timeout and the same timeout classification.
|
|
324
|
+
const fetchWithTimeout = makeTimedFetch(validateRequestTimeout(requestTimeoutMs));
|
|
251
325
|
const client = createClient(createConfig({
|
|
252
326
|
auth: () => apiKey,
|
|
253
327
|
baseUrl,
|
|
254
|
-
fetch:
|
|
328
|
+
fetch: fetchWithTimeout,
|
|
255
329
|
headers: { "User-Agent": USER_AGENT },
|
|
256
330
|
}));
|
|
257
331
|
/** Await a raw call, unwrap to a `{ data, status, headers }` envelope, or throw {@link NeonApiError}. */
|
|
@@ -306,7 +380,7 @@ export const getApiClient = ({ apiKey, apiHost }) => {
|
|
|
306
380
|
}
|
|
307
381
|
let response;
|
|
308
382
|
try {
|
|
309
|
-
response = await
|
|
383
|
+
response = await fetchWithTimeout(url, {
|
|
310
384
|
method: params.method,
|
|
311
385
|
headers,
|
|
312
386
|
...(payload !== undefined ? { body: payload } : {}),
|
|
@@ -346,6 +420,20 @@ export const getApiClient = ({ apiKey, apiHost }) => {
|
|
|
346
420
|
getCurrentUserOrganizations: () => call(() => raw.getCurrentUserOrganizations({ client })),
|
|
347
421
|
getAuthDetails: () => call(() => raw.getAuthDetails({ client })),
|
|
348
422
|
getActiveRegions: () => call(() => raw.getActiveRegions({ client })),
|
|
423
|
+
// ─── API keys ────────────────────────────────────────────────────────
|
|
424
|
+
// Account keys reach everything the account can. Org keys can additionally be
|
|
425
|
+
// narrowed to a single project via `project_id`, which is the only way to mint a
|
|
426
|
+
// least-privilege credential — so the org endpoints are not merely the org
|
|
427
|
+
// equivalent of the account ones.
|
|
428
|
+
listApiKeys: () => call(() => raw.listApiKeys({ client })),
|
|
429
|
+
createApiKey: (body) => call(() => raw.createApiKey({ client, body })),
|
|
430
|
+
revokeApiKey: (keyId) => call(() => raw.revokeApiKey({ client, path: { key_id: keyId } })),
|
|
431
|
+
listOrgApiKeys: (orgId) => call(() => raw.listOrgApiKeys({ client, path: { org_id: orgId } })),
|
|
432
|
+
createOrgApiKey: (orgId, body) => call(() => raw.createOrgApiKey({ client, path: { org_id: orgId }, body })),
|
|
433
|
+
revokeOrgApiKey: (orgId, keyId) => call(() => raw.revokeOrgApiKey({
|
|
434
|
+
client,
|
|
435
|
+
path: { org_id: orgId, key_id: keyId },
|
|
436
|
+
})),
|
|
349
437
|
// ─── Projects ────────────────────────────────────────────────────────
|
|
350
438
|
listProjects: (query = {}) => call(() => raw.listProjects({ client, query })),
|
|
351
439
|
listSharedProjects: (query = {}) => call(() => raw.listSharedProjects({
|