neon 2.43.0 → 2.45.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.
@@ -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 `@neon/config/paths`). Nothing is created until a second
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, writeFileSync } from "node:fs";
43
+ import { existsSync, readFileSync } from "node:fs";
44
44
  import { isAbsolute, relative, resolve } from "node:path";
45
- import { resolveConfigFile } from "@neon/config/paths";
46
- import { credentialsPath, defaultDir } from "./config.js";
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`, or `null` when there isn't one — the normal single-account state.
64
- * A malformed file is reported and treated as absent rather than breaking every command;
65
- * the worst case is that a named profile is "not found", which is recoverable, whereas
66
- * throwing here would lock the user out of `neon auth` itself.
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 readProfiles = (dir) => {
69
+ export const inspectProfiles = (dir) => {
69
70
  const path = profilesFilePath(dir);
70
71
  if (!existsSync(path))
71
- return null;
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
- const parsed = JSON.parse(readFileSync(path, "utf8"));
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
- log.warning("Ignoring malformed %s: %s", path, err instanceof Error ? err.message : String(err));
87
- return null;
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 file = readProfiles(dir);
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 auth --profile ${name}\`.`);
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 file = readProfiles(dir) ?? {
132
- version: 1,
133
- profiles: {
134
- [DEFAULT_PROFILE]: {
135
- credentials: relativeToProfiles(path, credentialsPath(dir)),
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 file = readProfiles(dir);
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
- writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
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 credentials = readFileSync(credentialsPath(args.configDir), {
60
- encoding: "utf-8",
61
- });
62
- userId = JSON.parse(credentials).user_id;
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("Failed to read credentials file", err);
75
+ log.debug("Could not read %s: %s", authenticatedAs, err);
66
76
  }
67
77
  try {
68
78
  if (args.apiKey) {
@@ -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 config directory whose credentials a 401 should clear, or `null` to leave
8
- * stored credentials alone.
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
- * An API key passed on the command line never touches the stored OAuth token,
11
- * so a 401 on that key says nothing about whether the stored credentials are
12
- * still good — clearing them would sign the user out of an account the failed
13
- * request never used. The directory comes from the context rather than the
14
- * default so that `--config-dir` isolates the deletion too.
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) => context?.source === "stored-credentials" ? context.configDir : null;
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 (props, projectId) => {
331
+ export const orgIdForProject = async (client, projectId) => {
380
332
  let orgId;
381
333
  try {
382
- const { data: { project }, } = await props.apiClient.getProject(projectId);
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. Create an account key by omitting --project-id.`);
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
  };