neon 2.43.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 +111 -15
- 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/auth_context.js +53 -8
- package/dist/commands/api_keys.js +8 -56
- package/dist/commands/auth.js +131 -59
- package/dist/commands/bootstrap.js +16 -3
- package/dist/commands/init.js +17 -0
- package/dist/commands/profile.js +835 -45
- package/dist/config.js +1 -22
- package/dist/context.js +7 -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 +12 -12
|
@@ -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/auth_context.js
CHANGED
|
@@ -1,16 +1,61 @@
|
|
|
1
|
+
import { isOwnedCredentialPath } from "./config.js";
|
|
1
2
|
let current = null;
|
|
2
3
|
export const setAuthContext = (context) => {
|
|
3
4
|
current = context;
|
|
4
5
|
};
|
|
5
6
|
export const getAuthContext = () => current;
|
|
7
|
+
/** Reset between tests, so one case cannot observe another's authentication. */
|
|
8
|
+
export const clearAuthContext = () => {
|
|
9
|
+
current = null;
|
|
10
|
+
};
|
|
6
11
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
12
|
+
* The credentials file a 401 should delete, or `null` to leave everything on disk.
|
|
13
|
+
*
|
|
14
|
+
* Only an expired OAuth token set is worth clearing: deleting it makes the next command log
|
|
15
|
+
* in again, which is the recovery. Neither key-shaped source is.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* A key passed on the command line was never ours to store, so a 401 on it says nothing about
|
|
18
|
+
* any stored credential — clearing one would sign the user out of an account the failed
|
|
19
|
+
* request never used.
|
|
20
|
+
*
|
|
21
|
+
* A key read from a profile must survive for a sharper reason: unlike an OAuth token there is
|
|
22
|
+
* nothing to refresh and no automatic way back, so deleting it would destroy the only copy of
|
|
23
|
+
* a credential the user has to paste or mint again. It is also the expected state during
|
|
24
|
+
* rotation, where the whole point is that the old key is dead and the file must still be there
|
|
25
|
+
* to be replaced.
|
|
15
26
|
*/
|
|
16
|
-
export const credentialsToClearOn401 = (context) =>
|
|
27
|
+
export const credentialsToClearOn401 = (context) => {
|
|
28
|
+
if (context?.source !== "stored-credentials")
|
|
29
|
+
return null;
|
|
30
|
+
const path = context.credentialsPath;
|
|
31
|
+
if (path === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
// Only a file the CLI created. A profile entry may point anywhere, and a credentials file
|
|
34
|
+
// we merely adopted is not ours to delete — `neon profile remove` already refuses to touch
|
|
35
|
+
// one, so a 401 must not quietly do what an explicit removal declines to.
|
|
36
|
+
//
|
|
37
|
+
// The legacy `neonctl` directory counts as ours: default resolution deliberately still
|
|
38
|
+
// reads it in place, so an install predating the rename would otherwise have its own
|
|
39
|
+
// credentials called "adopted" and never cleared.
|
|
40
|
+
return isOwnedCredentialPath(context.configDir, path) ? path : null;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* What to tell the user when the API rejects their credential, naming the profile and file
|
|
44
|
+
* when one is involved so they know which of several accounts failed and what to re-run.
|
|
45
|
+
*/
|
|
46
|
+
export const authFailureMessage = (context) => {
|
|
47
|
+
const profile = context?.profile ?? "the selected profile";
|
|
48
|
+
const where = context?.credentialsPath !== undefined
|
|
49
|
+
? ` (${context.credentialsPath})`
|
|
50
|
+
: "";
|
|
51
|
+
if (context?.source === "profile-api-key") {
|
|
52
|
+
// Not `rotate-key`: a rejected key cannot authenticate to mint its own replacement.
|
|
53
|
+
return `Authentication failed: the Neon API rejected profile "${profile}"'s API key${where}. Replace it with \`neon profile create ${profile} --mint --force\`, or store another with \`neon profile create ${profile} --api-key - --force\`.`;
|
|
54
|
+
}
|
|
55
|
+
// Reached only when the session was not ours to clear, i.e. an adopted credentials file.
|
|
56
|
+
// Saying "check --api-key" there would be nonsense; the fix is to sign in again.
|
|
57
|
+
if (context?.source === "stored-credentials") {
|
|
58
|
+
return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session${where}. That file was not created by neon, so it was left alone — sign in again with \`neon auth --profile ${profile}\`.`;
|
|
59
|
+
}
|
|
60
|
+
return "Authentication failed: the Neon API rejected the API key. Check --api-key or NEON_API_KEY.";
|
|
61
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isNeonApiError } from "../api.js";
|
|
2
2
|
import { log } from "../log.js";
|
|
3
|
+
import { noPassthrough, single } from "../utils/flags.js";
|
|
3
4
|
import { writer } from "../writer.js";
|
|
4
5
|
const ACCOUNT_FIELDS = [
|
|
5
6
|
"id",
|
|
@@ -43,55 +44,6 @@ const ALL_PROJECTS = "(all projects)";
|
|
|
43
44
|
* its correctly-scoped key rejected and revoked.
|
|
44
45
|
*/
|
|
45
46
|
const NO_PROJECT = Symbol("no-project");
|
|
46
|
-
/**
|
|
47
|
-
* A flag is either absent, or exactly one non-empty string. Anything else is an error.
|
|
48
|
-
*
|
|
49
|
-
* Every rejected shape otherwise ends the same way: the flag reads as falsy, the scope check
|
|
50
|
-
* falls through, and an **account** key is minted instead of the narrow one asked for — the
|
|
51
|
-
* worst thing this command can do, so none of them get a lenient reading.
|
|
52
|
-
*
|
|
53
|
-
* - `--project-id ""` — an unset shell variable; empty string, which is falsy.
|
|
54
|
-
* - `--no-project-id` — yargs boolean negation; `false`, which is falsy.
|
|
55
|
-
* - `--project-id a --project-id b` — an array, which would reach the API as `a,b`.
|
|
56
|
-
*
|
|
57
|
-
* A misspelled flag never binds and cannot be seen here; `.strict()` rejects it. Anything
|
|
58
|
-
* after a `--` terminator is handled by {@link noPassthrough}.
|
|
59
|
-
*/
|
|
60
|
-
const single = (name, { required = false } = {}) => (value) => {
|
|
61
|
-
if (value === undefined)
|
|
62
|
-
return undefined;
|
|
63
|
-
if (Array.isArray(value)) {
|
|
64
|
-
throw new Error(`--${name} was given more than once. Pass it at most once.`);
|
|
65
|
-
}
|
|
66
|
-
// `--no-x` is the negation form and yields `false`, so name it rather than telling
|
|
67
|
-
// the user their value was empty when they never gave one.
|
|
68
|
-
if (value === false) {
|
|
69
|
-
throw new Error(required
|
|
70
|
-
? `--no-${name} is not valid: --${name} is required.`
|
|
71
|
-
: `--no-${name} is not a valid way to skip --${name}. Omit the flag entirely.`);
|
|
72
|
-
}
|
|
73
|
-
if (typeof value !== "string" || value.trim() === "") {
|
|
74
|
-
throw new Error(required
|
|
75
|
-
? `--${name} needs a value.`
|
|
76
|
-
: `--${name} needs a value. Pass one, or omit the flag entirely.`);
|
|
77
|
-
}
|
|
78
|
-
return value;
|
|
79
|
-
};
|
|
80
|
-
/**
|
|
81
|
-
* Refuse arguments after a `--` terminator.
|
|
82
|
-
*
|
|
83
|
-
* The CLI sets `populate--`, so everything past `--` lands in `argv["--"]` where `.strict()`
|
|
84
|
-
* never looks — `create --name x -- --project-id p` would parse cleanly and mint an account
|
|
85
|
-
* key from a line that names the scope flag. No `api-keys` subcommand takes passthrough
|
|
86
|
-
* arguments, so their presence is always a mistake.
|
|
87
|
-
*/
|
|
88
|
-
const noPassthrough = (argv) => {
|
|
89
|
-
const rest = argv["--"];
|
|
90
|
-
if (Array.isArray(rest) && rest.length > 0) {
|
|
91
|
-
throw new Error(`api-keys takes no arguments after \`--\`, and options placed there are ignored rather than applied. Remove the \`--\`.`);
|
|
92
|
-
}
|
|
93
|
-
return true;
|
|
94
|
-
};
|
|
95
47
|
export const command = "api-keys";
|
|
96
48
|
export const aliases = ["api-key"];
|
|
97
49
|
export const describe = "Manage API keys";
|
|
@@ -106,7 +58,7 @@ export const builder = (argv) => argv
|
|
|
106
58
|
},
|
|
107
59
|
})
|
|
108
60
|
.strict()
|
|
109
|
-
.check(noPassthrough), async (args) => await list(args))
|
|
61
|
+
.check(noPassthrough("api-keys")), async (args) => await list(args))
|
|
110
62
|
.command("create", "Create an API key. The key is shown once and cannot be retrieved again", (yargs) => yargs
|
|
111
63
|
.options({
|
|
112
64
|
name: {
|
|
@@ -131,7 +83,7 @@ export const builder = (argv) => argv
|
|
|
131
83
|
// the project and cannot be chosen independently.
|
|
132
84
|
.conflicts("org-id", "project-id")
|
|
133
85
|
.strict()
|
|
134
|
-
.check(noPassthrough), async (args) => await create(args))
|
|
86
|
+
.check(noPassthrough("api-keys")), async (args) => await create(args))
|
|
135
87
|
.command("revoke <id>", "Revoke an API key. Anything using it stops working immediately", (yargs) => yargs
|
|
136
88
|
.positional("id", {
|
|
137
89
|
describe: "The API key id, from `api-keys list`",
|
|
@@ -165,7 +117,7 @@ export const builder = (argv) => argv
|
|
|
165
117
|
},
|
|
166
118
|
})
|
|
167
119
|
.strict()
|
|
168
|
-
.check(noPassthrough), async (args) => await revoke(args))
|
|
120
|
+
.check(noPassthrough("api-keys")), async (args) => await revoke(args))
|
|
169
121
|
.demandCommand(1, "Run `neon api-keys --help` to see the subcommands.");
|
|
170
122
|
export const handler = (args) => args;
|
|
171
123
|
const list = async (props) => {
|
|
@@ -237,7 +189,7 @@ const create = async (props) => {
|
|
|
237
189
|
// Project-scoped keys exist only on the organization endpoint, so an org is required.
|
|
238
190
|
// Resolve it from the project rather than asking for both: `--project-id` alone would
|
|
239
191
|
// otherwise fail for a reason that isn't visible from the command line.
|
|
240
|
-
const resolvedOrgId = await orgIdForProject(props, scopeTo);
|
|
192
|
+
const resolvedOrgId = await orgIdForProject(props.apiClient, scopeTo);
|
|
241
193
|
const { data } = await props.apiClient.createOrgApiKey(resolvedOrgId, {
|
|
242
194
|
key_name: name,
|
|
243
195
|
project_id: scopeTo,
|
|
@@ -376,10 +328,10 @@ const revoke = async (props) => {
|
|
|
376
328
|
* scoped key — the endpoint that accepts `project_id` is org-only — so that case fails here
|
|
377
329
|
* with the reason, rather than as a 404 from a URL the user never typed.
|
|
378
330
|
*/
|
|
379
|
-
const orgIdForProject = async (
|
|
331
|
+
export const orgIdForProject = async (client, projectId) => {
|
|
380
332
|
let orgId;
|
|
381
333
|
try {
|
|
382
|
-
const { data: { project }, } = await
|
|
334
|
+
const { data: { project }, } = await client.getProject(projectId);
|
|
383
335
|
orgId = project.org_id;
|
|
384
336
|
}
|
|
385
337
|
catch (err) {
|
|
@@ -391,7 +343,7 @@ const orgIdForProject = async (props, projectId) => {
|
|
|
391
343
|
throw err;
|
|
392
344
|
}
|
|
393
345
|
if (!orgId) {
|
|
394
|
-
throw new Error(`Project ${projectId} does not belong to an organization, so it cannot have a project-scoped API key.
|
|
346
|
+
throw new Error(`Project ${projectId} does not belong to an organization, so it cannot have a project-scoped API key. Omit --project-id to create an account key.`);
|
|
395
347
|
}
|
|
396
348
|
return orgId;
|
|
397
349
|
};
|