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.
@@ -1,5 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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 this invocation reads and writes: the selected profile's, falling
14
- * back to plain `credentials.json` when no profile was named and none is declared. An
15
- * unknown profile name is a hard error rather than a silent write to the default file —
16
- * a typo must not authenticate the wrong account.
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 credentialsPathFor = ({ configDir, profile, }) => {
19
- const name = selectProfileName(profile);
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
- const credentialsPath = isNamed && !readProfiles(configDir)?.profiles[profileName]
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
- const contents = JSON.stringify({
83
- // Cast to a plain record: we intentionally spread the credentials object.
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
- user_id: id,
86
- });
87
- // Owner-only. A credentials file needs read/write, never execute.
88
- writeFileSync(path, contents, {
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(contents));
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
- // Use existing API key or handle auth command
171
- if (props.apiKey || props._[0] === "auth") {
172
- if (props.apiKey) {
173
- log.debug("Using an API key to authorize requests");
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
- const credentialsPath = credentialsPathFor(props);
186
- // Handle case when credentials file exists
187
- if (existsSync(credentialsPath)) {
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 contents = readFileSync(credentialsPath, "utf8");
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
- // Fall through to new auth flow for auth failures
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("Credentials file %s does not exist, starting authentication", credentialsPath);
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
- const apiKey = await authFlow(props);
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 the credentials backing a profile — used by the 401 handler to clear a token the
249
- * API has rejected, so the next command re-authenticates instead of failing again.
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
- * @param configDir Directory the credentials live in
252
- * @param profile Profile whose credentials to clear. Defaults to the selected one.
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 deleteCredentials = (configDir, profile) => {
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
- if (props.apiKey) {
330
- args.push("--api-key", props.apiKey);
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
- await runCommand(process.execPath, args, targetDir);
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("");
@@ -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