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
package/dist/commands/auth.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { credentialInputs, displacedProfileWarning, selectCredential, } from "../_shared/auth_selection.js";
|
|
4
|
+
import { API_KEY, interpretCredentials, OAUTH, readCredentials, writeCredentials, } from "../_shared/credentials.js";
|
|
5
|
+
import { assertProfilesUsable, assertValidProfileName, DEFAULT_PROFILE, newProfileCredentialsPath, readProfiles, resolveProfile, selectProfileName, upsertProfile, } from "../_shared/profiles.js";
|
|
3
6
|
import { getApiClient } from "../api.js";
|
|
4
7
|
import { auth, refreshToken } from "../auth.js";
|
|
5
8
|
import { setAuthContext } from "../auth_context.js";
|
|
@@ -7,20 +10,19 @@ import { credentialsPath as defaultCredentialsPath } from "../config.js";
|
|
|
7
10
|
import { isConfigInit, isCurrentBranchProbe, isProfileCommand, } from "../context.js";
|
|
8
11
|
import { isCi } from "../env.js";
|
|
9
12
|
import { log } from "../log.js";
|
|
10
|
-
import { assertValidProfileName, DEFAULT_PROFILE, newProfileCredentialsPath, readProfiles, resolveProfile, selectProfileName, upsertProfile, } from "../profiles.js";
|
|
11
13
|
import { extendTokenSet } from "../utils/auth.js";
|
|
12
14
|
/**
|
|
13
|
-
* The credentials file
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* The credentials file a named profile resolves to, falling back to plain `credentials.json`
|
|
16
|
+
* when the name is `DEFAULT` and nothing is declared. An unknown profile name is a hard error
|
|
17
|
+
* rather than a silent write to the default file — a typo must not authenticate the wrong
|
|
18
|
+
* account.
|
|
17
19
|
*/
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
if (name === DEFAULT_PROFILE && !readProfiles(configDir))
|
|
20
|
+
export const credentialsPathForName = (configDir, name) => {
|
|
21
|
+
if (name === DEFAULT_PROFILE && !readProfiles(configDir, log.warning))
|
|
21
22
|
return defaultCredentialsPath(configDir);
|
|
22
23
|
return resolveProfile(configDir, name).credentialsPath;
|
|
23
24
|
};
|
|
25
|
+
const credentialsPathFor = ({ configDir, profile, }) => credentialsPathForName(configDir, selectProfileName(profile));
|
|
24
26
|
export const command = "auth";
|
|
25
27
|
export const aliases = ["login"];
|
|
26
28
|
export const describe = "Authenticate";
|
|
@@ -35,11 +37,6 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
|
|
|
35
37
|
if (!allowInteractiveAuth && isCi()) {
|
|
36
38
|
throw new Error("Cannot run interactive auth in CI");
|
|
37
39
|
}
|
|
38
|
-
const tokenSet = await auth({
|
|
39
|
-
oauthHost: oauthHost,
|
|
40
|
-
clientId: clientId,
|
|
41
|
-
allowUnsafeTls,
|
|
42
|
-
});
|
|
43
40
|
// A named profile that doesn't exist yet is created here rather than erroring: `neon
|
|
44
41
|
// auth --profile work` is how you make one, so it must work before there is anything
|
|
45
42
|
// to look up.
|
|
@@ -47,7 +44,16 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
|
|
|
47
44
|
const isNamed = profileName !== DEFAULT_PROFILE;
|
|
48
45
|
if (isNamed)
|
|
49
46
|
assertValidProfileName(profileName);
|
|
50
|
-
|
|
47
|
+
// Both checks belong before the browser opens. Signing in and then refusing costs a real
|
|
48
|
+
// sign-in, and worse, the write in between lands on a path chosen from metadata this
|
|
49
|
+
// refuses to trust.
|
|
50
|
+
assertProfilesUsable(configDir, profileName);
|
|
51
|
+
const tokenSet = await auth({
|
|
52
|
+
oauthHost: oauthHost,
|
|
53
|
+
clientId: clientId,
|
|
54
|
+
allowUnsafeTls,
|
|
55
|
+
});
|
|
56
|
+
const credentialsPath = isNamed && !readProfiles(configDir, log.warning)?.profiles[profileName]
|
|
51
57
|
? newProfileCredentialsPath(configDir, profileName)
|
|
52
58
|
: credentialsPathFor({ configDir, profile });
|
|
53
59
|
let identity = {};
|
|
@@ -79,17 +85,18 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
|
|
|
79
85
|
*/
|
|
80
86
|
const preserveCredentials = async (path, credentials, apiClient) => {
|
|
81
87
|
const { data: { id, email }, } = await apiClient.getCurrentUserInfo();
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
// Replace rather than merge, and declare the kind. Signing in makes this an OAuth
|
|
89
|
+
// credential and nothing of a previous one is carried over: a retained API key would leave
|
|
90
|
+
// the file holding two credentials, possibly for two different accounts, with a single
|
|
91
|
+
// field deciding which one is live.
|
|
92
|
+
const stored = {
|
|
84
93
|
...credentials,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
mode: 0o600,
|
|
90
|
-
});
|
|
94
|
+
type: OAUTH,
|
|
95
|
+
...(id !== undefined ? { user_id: id } : {}),
|
|
96
|
+
};
|
|
97
|
+
writeCredentials(path, stored);
|
|
91
98
|
log.debug("Saved credentials to %s", path);
|
|
92
|
-
log.debug("Credentials MD5 hash: %s", md5hash(
|
|
99
|
+
log.debug("Credentials MD5 hash: %s", md5hash(JSON.stringify(stored)));
|
|
93
100
|
return { ...(id ? { id } : {}), ...(email ? { email } : {}) };
|
|
94
101
|
};
|
|
95
102
|
const handleExistingToken = async (tokenSet, props, credentialsPath) => {
|
|
@@ -133,6 +140,34 @@ const handleExistingToken = async (tokenSet, props, credentialsPath) => {
|
|
|
133
140
|
throw new Error("AUTH_REFRESH_FAILED");
|
|
134
141
|
}
|
|
135
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* The credential a stored file can authenticate with right now — its API key, or an OAuth
|
|
145
|
+
* access token, refreshed first when it has expired. Never launches a browser.
|
|
146
|
+
*
|
|
147
|
+
* The `profile` subcommands need exactly this. They deliberately skip `ensureAuth`, but
|
|
148
|
+
* `rotate-key` still has to call the API as the account it is rotating, and falling through to
|
|
149
|
+
* an interactive login would be authenticating something other than what the command is about.
|
|
150
|
+
*/
|
|
151
|
+
export const usableCredential = async (props, at) => {
|
|
152
|
+
const credentialsPath = at.path;
|
|
153
|
+
const stored = readCredentials(at);
|
|
154
|
+
if (stored === null)
|
|
155
|
+
return null;
|
|
156
|
+
// A file that declares an unusable kind throws, rather than being reported as "no
|
|
157
|
+
// credential" — the user needs to know it is broken, not that it is absent.
|
|
158
|
+
const credential = interpretCredentials(stored, at);
|
|
159
|
+
if (credential.kind === API_KEY) {
|
|
160
|
+
return { apiKey: credential.apiKey, kind: API_KEY };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const result = await handleExistingToken(stored, props, credentialsPath);
|
|
164
|
+
return result ? { apiKey: result.apiKey, kind: OAUTH } : null;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
log.debug("Could not use the stored OAuth session at %s: %s", credentialsPath, err instanceof Error ? err.message : "unknown error");
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
136
171
|
export const ensureAuth = async (props) => {
|
|
137
172
|
// Skip auth for help command or no command
|
|
138
173
|
if (props._.length === 0 || props.help) {
|
|
@@ -167,55 +202,89 @@ export const ensureAuth = async (props) => {
|
|
|
167
202
|
// `init` manages its own auth flow (asks the user if they have an account,
|
|
168
203
|
// then triggers OAuth at the right time). Skip the global auth middleware.
|
|
169
204
|
const isInit = props._[0] === "init";
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
setAuthContext({
|
|
175
|
-
source: "api-key",
|
|
176
|
-
configDir: props.configDir,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
205
|
+
// `auth` writes a credential rather than using one, and reads `--profile` as the
|
|
206
|
+
// destination to write it to. Running selection here would reject the flag pair it
|
|
207
|
+
// accepts, and could resolve a stored key that this command must not authenticate with.
|
|
208
|
+
if (props._[0] === "auth") {
|
|
179
209
|
props.apiClient = getApiClient({
|
|
180
210
|
apiKey: props.apiKey,
|
|
181
211
|
apiHost: props.apiHost,
|
|
182
212
|
});
|
|
183
213
|
return;
|
|
184
214
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
215
|
+
// Throws when `--api-key` and `--profile` are both passed.
|
|
216
|
+
const selection = selectCredential({
|
|
217
|
+
...credentialInputs(),
|
|
218
|
+
profileFlag: props.profile,
|
|
219
|
+
});
|
|
220
|
+
const displaced = displacedProfileWarning(selection);
|
|
221
|
+
if (displaced !== null) {
|
|
222
|
+
log.warning(displaced);
|
|
223
|
+
}
|
|
224
|
+
if (selection.source !== "profile") {
|
|
225
|
+
props.apiKey = selection.apiKey;
|
|
226
|
+
log.debug("Using an API key to authorize requests");
|
|
227
|
+
setAuthContext({
|
|
228
|
+
source: "api-key",
|
|
229
|
+
configDir: props.configDir,
|
|
230
|
+
});
|
|
231
|
+
props.apiClient = getApiClient({
|
|
232
|
+
apiKey: props.apiKey,
|
|
233
|
+
apiHost: props.apiHost,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// An explicit `--profile` outranks an exported `NEON_API_KEY`, which the middleware has
|
|
238
|
+
// already folded into `apiKey`. Clear it so nothing downstream — analytics, `dev`'s env
|
|
239
|
+
// runtime, a re-exec — authenticates with a key this invocation decided against.
|
|
240
|
+
props.apiKey = "";
|
|
241
|
+
const credentialsPath = credentialsPathForName(props.configDir, selection.profile);
|
|
242
|
+
const at = { path: credentialsPath, profile: selection.profile };
|
|
243
|
+
const stored = readCredentials(at);
|
|
244
|
+
if (stored !== null) {
|
|
188
245
|
log.debug("Trying to read credentials from %s", credentialsPath);
|
|
246
|
+
// Throws on a file whose declared kind is unusable. That is deliberate: falling
|
|
247
|
+
// through to a browser login would replace the credential the user is fixing.
|
|
248
|
+
const credential = interpretCredentials(stored, at);
|
|
249
|
+
if (credential.kind === API_KEY) {
|
|
250
|
+
log.debug('Using profile "%s"\'s API key to authorize requests', selection.profile);
|
|
251
|
+
props.apiKey = credential.apiKey;
|
|
252
|
+
setAuthContext({
|
|
253
|
+
source: "profile-api-key",
|
|
254
|
+
configDir: props.configDir,
|
|
255
|
+
profile: selection.profile,
|
|
256
|
+
credentialsPath,
|
|
257
|
+
});
|
|
258
|
+
props.apiClient = getApiClient({
|
|
259
|
+
apiKey: credential.apiKey,
|
|
260
|
+
apiHost: props.apiHost,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
189
264
|
try {
|
|
190
|
-
const
|
|
191
|
-
log.debug("Credentials MD5 hash: %s", md5hash(contents));
|
|
192
|
-
const tokenSet = JSON.parse(contents);
|
|
193
|
-
// Try to use existing token or refresh it
|
|
194
|
-
const result = await handleExistingToken(tokenSet, props, credentialsPath);
|
|
265
|
+
const result = await handleExistingToken(stored, props, credentialsPath);
|
|
195
266
|
if (result) {
|
|
196
267
|
props.apiKey = result.apiKey;
|
|
197
268
|
props.apiClient = result.apiClient;
|
|
198
269
|
setAuthContext({
|
|
199
270
|
source: "stored-credentials",
|
|
200
271
|
configDir: props.configDir,
|
|
272
|
+
profile: selection.profile,
|
|
273
|
+
credentialsPath,
|
|
201
274
|
});
|
|
202
275
|
return;
|
|
203
276
|
}
|
|
204
277
|
}
|
|
205
278
|
catch (err) {
|
|
206
|
-
if (!(err instanceof Error &&
|
|
207
|
-
err.message === "AUTH_REFRESH_FAILED") &&
|
|
208
|
-
err.code !== "ENOENT" &&
|
|
209
|
-
!(err instanceof SyntaxError)) {
|
|
210
|
-
// Throw for any errors except auth refresh failure, missing file, or invalid credentials file
|
|
279
|
+
if (!(err instanceof Error && err.message === "AUTH_REFRESH_FAILED")) {
|
|
211
280
|
throw err;
|
|
212
281
|
}
|
|
213
|
-
//
|
|
282
|
+
// A refresh that failed is recoverable by logging in again.
|
|
214
283
|
log.debug("Ensure auth failed, starting authentication", err);
|
|
215
284
|
}
|
|
216
285
|
}
|
|
217
286
|
else {
|
|
218
|
-
log.debug("
|
|
287
|
+
log.debug("No usable credentials at %s, starting authentication", credentialsPath);
|
|
219
288
|
}
|
|
220
289
|
// `dev` never launches the interactive browser flow. With no usable
|
|
221
290
|
// credentials it proceeds without an API client; env injection is skipped
|
|
@@ -232,8 +301,10 @@ export const ensureAuth = async (props) => {
|
|
|
232
301
|
log.debug("init: skipping global auth; init manages its own auth flow");
|
|
233
302
|
return;
|
|
234
303
|
}
|
|
235
|
-
// Start new auth flow if no valid token exists or refresh failed
|
|
236
|
-
|
|
304
|
+
// Start new auth flow if no valid token exists or refresh failed. Pass the resolved
|
|
305
|
+
// profile rather than the raw flag, so a name that came from `NEON_PROFILE` is written to
|
|
306
|
+
// its own file instead of overwriting `DEFAULT`'s.
|
|
307
|
+
const apiKey = await authFlow({ ...props, profile: selection.profile });
|
|
237
308
|
props.apiKey = apiKey;
|
|
238
309
|
props.apiClient = getApiClient({
|
|
239
310
|
apiKey,
|
|
@@ -242,20 +313,21 @@ export const ensureAuth = async (props) => {
|
|
|
242
313
|
setAuthContext({
|
|
243
314
|
source: "stored-credentials",
|
|
244
315
|
configDir: props.configDir,
|
|
316
|
+
profile: selection.profile,
|
|
317
|
+
credentialsPath,
|
|
245
318
|
});
|
|
246
319
|
};
|
|
247
320
|
/**
|
|
248
|
-
* Delete
|
|
249
|
-
*
|
|
321
|
+
* Delete one credentials file — used by the 401 handler to clear an OAuth token set the API
|
|
322
|
+
* has rejected, so the next command logs in again instead of failing the same way.
|
|
250
323
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
324
|
+
* It takes the exact path rather than a directory and an optional profile. Deriving the path
|
|
325
|
+
* here meant re-running selection from partial state, and the 401 handler has no parsed
|
|
326
|
+
* arguments: it passed only the config directory, so a rejected token on a
|
|
327
|
+
* `--profile`-selected account deleted whatever `DEFAULT` pointed at — signing the user out
|
|
328
|
+
* of an account whose credentials the failed request had never touched.
|
|
253
329
|
*/
|
|
254
|
-
export const
|
|
255
|
-
const credentialsPath = credentialsPathFor({
|
|
256
|
-
configDir,
|
|
257
|
-
...(profile ? { profile } : {}),
|
|
258
|
-
});
|
|
330
|
+
export const deleteCredentialsAt = (credentialsPath) => {
|
|
259
331
|
try {
|
|
260
332
|
if (existsSync(credentialsPath)) {
|
|
261
333
|
rmSync(credentialsPath);
|
|
@@ -3,6 +3,7 @@ import { join, relative, resolve } from "node:path";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { BootstrapInputError, ensureTargetUsable, FALLBACK_TEMPLATES, fetchTemplates, findTemplate, scaffoldTemplate, templateIds, } from "neon-init/bootstrap";
|
|
5
5
|
import prompts from "prompts";
|
|
6
|
+
import { credentialInputs } from "../_shared/auth_selection.js";
|
|
6
7
|
import { isCi } from "../env.js";
|
|
7
8
|
import { log } from "../log.js";
|
|
8
9
|
import { detectPackageManager, installedPackageManagers, runCommand, } from "../utils/package_manager.js";
|
|
@@ -326,11 +327,23 @@ const selectPackageManager = async () => {
|
|
|
326
327
|
*/
|
|
327
328
|
const runNeonLink = async (props, targetDir) => {
|
|
328
329
|
const args = [process.argv[1], "link"];
|
|
329
|
-
|
|
330
|
-
|
|
330
|
+
// Forward how to authenticate, never the credential itself. A key on the child's argv is
|
|
331
|
+
// visible to anything that can list processes, and `runCommand` prints the whole argument
|
|
332
|
+
// list when the command fails — so a failed link would put the key in the log too.
|
|
333
|
+
// Forwarding the selection also keeps the child on the same account: without `--profile` it
|
|
334
|
+
// would resolve its own, and `--config-dir` was never forwarded at all.
|
|
335
|
+
if (props.configDir) {
|
|
336
|
+
args.push("--config-dir", props.configDir);
|
|
337
|
+
}
|
|
338
|
+
if (props.profile) {
|
|
339
|
+
args.push("--profile", props.profile);
|
|
331
340
|
}
|
|
332
341
|
args.push("--api-host", props.apiHost, "--output", props.output);
|
|
333
|
-
|
|
342
|
+
// An explicit `--api-key` has nowhere else to go — the child cannot re-resolve a flag we
|
|
343
|
+
// were given — so it travels in the environment, which neither `ps` nor our own logging
|
|
344
|
+
// exposes. A profile needs none of this; the child reads the credential itself.
|
|
345
|
+
const explicitKey = props.profile ? "" : credentialInputs().apiKeyFlag;
|
|
346
|
+
await runCommand(process.execPath, args, targetDir, explicitKey ? { NEON_API_KEY: explicitKey } : undefined);
|
|
334
347
|
};
|
|
335
348
|
const printScaffolded = (template, targetDir) => {
|
|
336
349
|
log.info("");
|
package/dist/commands/init.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectAgent, enrichResponse, interactiveInit, orchestrate, routeDataStep, } from "neon-init";
|
|
2
|
+
import { credentialInputs } from "../_shared/auth_selection.js";
|
|
2
3
|
import { sendError } from "../analytics.js";
|
|
3
4
|
import { log } from "../log.js";
|
|
4
5
|
export const command = "init";
|
|
@@ -29,6 +30,22 @@ export const builder = (yargs) => yargs
|
|
|
29
30
|
})
|
|
30
31
|
.strict(false);
|
|
31
32
|
export const handler = async (argv) => {
|
|
33
|
+
// `init` delegates its whole auth flow to `neon-init`, which reads the default credentials
|
|
34
|
+
// directly and re-invokes the CLI as a subprocess. It has no way to be told which profile
|
|
35
|
+
// to use, so honouring a selection here is not possible yet — and silently running as the
|
|
36
|
+
// default account would be worse than refusing, because naming an account is the entire
|
|
37
|
+
// job of the thing being ignored.
|
|
38
|
+
//
|
|
39
|
+
// `NEON_PROFILE` counts just as much as the flag. Checking only the flag left the case that
|
|
40
|
+
// is easier to hit by accident: a profile exported once into a shell then silently
|
|
41
|
+
// disregarded by every `neon init` run in it.
|
|
42
|
+
const selectedProfile = argv.profile?.trim() || credentialInputs().profileEnv.trim();
|
|
43
|
+
if (selectedProfile) {
|
|
44
|
+
const how = argv.profile?.trim()
|
|
45
|
+
? "--profile was passed, so"
|
|
46
|
+
: "NEON_PROFILE is set, so";
|
|
47
|
+
throw new Error(`${how} \`neon init\` would run as the default account instead of "${selectedProfile}", and it does not support profile selection yet. Run it without one, or set the project up with \`neon --profile ${selectedProfile} link\`.`);
|
|
48
|
+
}
|
|
32
49
|
try {
|
|
33
50
|
// Auto-detect agent from environment. When --agent is explicitly passed,
|
|
34
51
|
// always detect (the user asked for agent mode). Otherwise, require
|