neon 3.0.0 → 3.1.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.
Files changed (198) hide show
  1. package/README.md +54 -0
  2. package/dist/_shared/auth_selection.js +76 -79
  3. package/dist/_shared/credentials.js +166 -187
  4. package/dist/_shared/env-core/env.js +354 -517
  5. package/dist/_shared/env-core/reuse-secrets.js +159 -203
  6. package/dist/_shared/paths.js +129 -126
  7. package/dist/_shared/profiles.js +192 -242
  8. package/dist/_shared/secure_file.js +36 -38
  9. package/dist/_virtual/_rolldown/runtime.js +13 -0
  10. package/dist/analytics.js +163 -207
  11. package/dist/api.js +815 -758
  12. package/dist/auth.js +121 -141
  13. package/dist/auth_context.js +39 -53
  14. package/dist/cli.js +4 -7
  15. package/dist/commands/api.js +220 -250
  16. package/dist/commands/api_keys.js +251 -314
  17. package/dist/commands/auth.js +283 -328
  18. package/dist/commands/bootstrap.js +372 -437
  19. package/dist/commands/branches.js +304 -455
  20. package/dist/commands/bucket.js +374 -514
  21. package/dist/commands/checkout.js +213 -298
  22. package/dist/commands/config.js +573 -690
  23. package/dist/commands/connection_string.js +137 -165
  24. package/dist/commands/data_api.js +238 -260
  25. package/dist/commands/databases.js +67 -76
  26. package/dist/commands/deploy.js +31 -25
  27. package/dist/commands/dev.js +639 -719
  28. package/dist/commands/diff.js +156 -200
  29. package/dist/commands/env.js +243 -303
  30. package/dist/commands/functions.js +275 -355
  31. package/dist/commands/index.js +70 -65
  32. package/dist/commands/init.js +84 -119
  33. package/dist/commands/inspect.js +55 -55
  34. package/dist/commands/ip_allow.js +88 -120
  35. package/dist/commands/link.js +874 -1019
  36. package/dist/commands/logs.js +291 -0
  37. package/dist/commands/neon_auth.js +725 -933
  38. package/dist/commands/operations.js +34 -25
  39. package/dist/commands/orgs.js +28 -18
  40. package/dist/commands/profile.js +614 -845
  41. package/dist/commands/projects.js +313 -373
  42. package/dist/commands/psql.js +60 -58
  43. package/dist/commands/roles.js +55 -58
  44. package/dist/commands/schema_diff.js +87 -131
  45. package/dist/commands/set_context.js +34 -26
  46. package/dist/commands/snapshots.js +288 -413
  47. package/dist/commands/status.js +41 -37
  48. package/dist/commands/user.js +21 -10
  49. package/dist/commands/vpc_endpoints.js +85 -113
  50. package/dist/config.js +7 -6
  51. package/dist/config_format.js +50 -66
  52. package/dist/config_template.js +128 -157
  53. package/dist/context.js +183 -235
  54. package/dist/current_branch_fast_path.js +40 -49
  55. package/dist/dev/env.js +313 -394
  56. package/dist/dev/functions.js +54 -68
  57. package/dist/dev/inputs.js +46 -58
  58. package/dist/dev/runtime.js +135 -164
  59. package/dist/dev/websocket.js +766 -959
  60. package/dist/env.js +27 -33
  61. package/dist/env_file.js +118 -132
  62. package/dist/env_services.js +36 -38
  63. package/dist/errors.js +57 -68
  64. package/dist/functions_api.js +45 -43
  65. package/dist/help.js +189 -140
  66. package/dist/index.js +182 -257
  67. package/dist/init/agents.js +137 -118
  68. package/dist/init/auth.js +58 -68
  69. package/dist/init/bootstrap.js +325 -396
  70. package/dist/init/build_config.js +4 -2
  71. package/dist/init/detect_agent.js +56 -101
  72. package/dist/init/editors.js +35 -52
  73. package/dist/init/enrich_output.js +51 -66
  74. package/dist/init/extension.js +134 -171
  75. package/dist/init/inspect.js +179 -266
  76. package/dist/init/interactive.js +510 -622
  77. package/dist/init/neonctl.js +117 -168
  78. package/dist/init/orchestrate.js +157 -173
  79. package/dist/init/phases/auth.js +188 -202
  80. package/dist/init/phases/cleanup.js +23 -23
  81. package/dist/init/phases/db.js +251 -277
  82. package/dist/init/phases/getting_started.js +213 -223
  83. package/dist/init/phases/mcp.js +174 -224
  84. package/dist/init/phases/migrations.js +247 -248
  85. package/dist/init/phases/neon_auth.js +114 -133
  86. package/dist/init/phases/setup.js +546 -703
  87. package/dist/init/phases/skills.js +75 -86
  88. package/dist/init/phases/status.js +72 -67
  89. package/dist/init/resolve_context.js +102 -99
  90. package/dist/init/route_command.js +91 -98
  91. package/dist/init/skills.js +174 -218
  92. package/dist/init/vsix.js +77 -99
  93. package/dist/log.js +17 -16
  94. package/dist/neon_services.js +104 -129
  95. package/dist/parameters.gen.js +481 -471
  96. package/dist/pkg.js +17 -19
  97. package/dist/profile_keys.js +44 -47
  98. package/dist/psql/cli.js +44 -47
  99. package/dist/psql/command/cmd_cond.js +231 -406
  100. package/dist/psql/command/cmd_connect.js +557 -764
  101. package/dist/psql/command/cmd_copy.js +727 -983
  102. package/dist/psql/command/cmd_describe.js +1499 -1688
  103. package/dist/psql/command/cmd_format.js +733 -905
  104. package/dist/psql/command/cmd_io.js +1293 -2082
  105. package/dist/psql/command/cmd_lo.js +297 -359
  106. package/dist/psql/command/cmd_meta.js +727 -878
  107. package/dist/psql/command/cmd_misc.js +138 -172
  108. package/dist/psql/command/cmd_pipeline.js +547 -1099
  109. package/dist/psql/command/cmd_restrict.js +119 -155
  110. package/dist/psql/command/cmd_show.js +529 -688
  111. package/dist/psql/command/dispatch.js +261 -325
  112. package/dist/psql/command/inputQueue.js +35 -33
  113. package/dist/psql/command/shared.js +49 -63
  114. package/dist/psql/complete/filenames.js +90 -133
  115. package/dist/psql/complete/index.js +59 -97
  116. package/dist/psql/complete/matcher.js +236 -300
  117. package/dist/psql/complete/psqlVars.js +218 -223
  118. package/dist/psql/complete/queries.js +159 -177
  119. package/dist/psql/complete/rules.js +1493 -2299
  120. package/dist/psql/core/common.js +762 -1180
  121. package/dist/psql/core/help.js +456 -546
  122. package/dist/psql/core/mainloop.js +692 -1302
  123. package/dist/psql/core/prompt.js +391 -408
  124. package/dist/psql/core/settings.js +429 -644
  125. package/dist/psql/core/sqlHelp.js +480 -554
  126. package/dist/psql/core/startup.js +626 -815
  127. package/dist/psql/core/syncVars.js +67 -110
  128. package/dist/psql/core/variables.js +156 -278
  129. package/dist/psql/describe/formatters.js +884 -1285
  130. package/dist/psql/describe/processNamePattern.js +173 -260
  131. package/dist/psql/describe/queries.js +1368 -2403
  132. package/dist/psql/describe/versionGate.js +32 -41
  133. package/dist/psql/index.js +1414 -1927
  134. package/dist/psql/io/history.js +232 -271
  135. package/dist/psql/io/input.js +103 -108
  136. package/dist/psql/io/lineEditor/buffer.js +238 -319
  137. package/dist/psql/io/lineEditor/complete.js +135 -213
  138. package/dist/psql/io/lineEditor/filename.js +139 -148
  139. package/dist/psql/io/lineEditor/index.js +653 -870
  140. package/dist/psql/io/lineEditor/keymap.js +544 -702
  141. package/dist/psql/io/lineEditor/vt100.js +294 -341
  142. package/dist/psql/io/pgpass.js +158 -187
  143. package/dist/psql/io/pgservice.js +146 -183
  144. package/dist/psql/io/psqlrc.js +328 -403
  145. package/dist/psql/print/aligned.js +1020 -1683
  146. package/dist/psql/print/asciidoc.js +180 -214
  147. package/dist/psql/print/crosstab.js +281 -442
  148. package/dist/psql/print/csv.js +48 -70
  149. package/dist/psql/print/html.js +195 -226
  150. package/dist/psql/print/json.js +75 -88
  151. package/dist/psql/print/latex.js +291 -364
  152. package/dist/psql/print/pager.js +171 -242
  153. package/dist/psql/print/troff.js +194 -226
  154. package/dist/psql/print/unaligned.js +69 -95
  155. package/dist/psql/print/units.js +167 -169
  156. package/dist/psql/scanner/slash.js +428 -483
  157. package/dist/psql/scanner/sql.js +445 -889
  158. package/dist/psql/scanner/stringutils.js +309 -379
  159. package/dist/psql/types/index.js +2 -7
  160. package/dist/psql/types/scanner.js +25 -22
  161. package/dist/psql/wire/connection.js +2042 -2803
  162. package/dist/psql/wire/copy.js +84 -100
  163. package/dist/psql/wire/notify.js +39 -59
  164. package/dist/psql/wire/pipeline.js +305 -518
  165. package/dist/psql/wire/protocol.js +349 -417
  166. package/dist/psql/wire/sasl.js +180 -265
  167. package/dist/psql/wire/tls.js +400 -561
  168. package/dist/storage_api.js +115 -129
  169. package/dist/test_utils/fixtures.js +94 -113
  170. package/dist/test_utils/oauth_server.js +10 -7
  171. package/dist/test_utils/project_dir.js +33 -0
  172. package/dist/utils/ai_gateway_notice.js +131 -162
  173. package/dist/utils/api_enums.js +21 -28
  174. package/dist/utils/auth.js +10 -4
  175. package/dist/utils/branch_notice.js +20 -19
  176. package/dist/utils/branch_picker.js +83 -89
  177. package/dist/utils/cli_name.js +15 -12
  178. package/dist/utils/compute_units.js +20 -27
  179. package/dist/utils/config_diff.js +127 -158
  180. package/dist/utils/enrichers.js +95 -148
  181. package/dist/utils/esbuild.js +130 -189
  182. package/dist/utils/flags.js +35 -47
  183. package/dist/utils/formats.js +8 -15
  184. package/dist/utils/git_diff.js +69 -80
  185. package/dist/utils/inspect_db.js +101 -143
  186. package/dist/utils/inspect_queries.js +179 -142
  187. package/dist/utils/middlewares.js +37 -44
  188. package/dist/utils/openapi.js +87 -99
  189. package/dist/utils/package_manager.js +312 -110
  190. package/dist/utils/point_in_time.js +49 -53
  191. package/dist/utils/psql.js +89 -106
  192. package/dist/utils/service_picker.js +55 -58
  193. package/dist/utils/string.js +5 -5
  194. package/dist/utils/ui.js +38 -55
  195. package/dist/utils/write_sync.js +26 -35
  196. package/dist/utils/zip.js +4 -3
  197. package/dist/writer.js +67 -87
  198. package/package.json +7 -5
@@ -1,913 +1,682 @@
1
- import { existsSync, readFileSync, rmSync } from "node:fs";
2
- import { basename } from "node:path";
3
- import prompts from "prompts";
4
- import { credentialInputs } from "../_shared/auth_selection.js";
5
- import { API_KEY, apiKeyCredentials, credentialKind, describeScope, inspectCredentials, interpretCredentials, isSameCredential, OAUTH, readCredentials, scopeOf, writeCredentials, } from "../_shared/credentials.js";
6
- import { assertProfilesUsable, assertValidProfileName, DEFAULT_PROFILE, listProfiles, newProfileCredentialsPath, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, upsertProfile, } from "../_shared/profiles.js";
1
+ import { __exportAll } from "../_virtual/_rolldown/runtime.js";
7
2
  import { writeSecretFile } from "../_shared/secure_file.js";
8
- import { getApiClient, isNeonApiError } from "../api.js";
9
- import { auth, revokeToken } from "../auth.js";
10
- import { setAuthContext } from "../auth_context.js";
11
- import { isInsideConfigDir } from "../config.js";
3
+ import { API_KEY, OAUTH, apiKeyCredentials, credentialKind, describeScope, inspectCredentials, interpretCredentials, isSameCredential, readCredentials, scopeOf, writeCredentials } from "../_shared/credentials.js";
12
4
  import { isCi } from "../env.js";
13
5
  import { log } from "../log.js";
14
- import { identityFromAuthDetails, isApiKeyMethod, mintedKeyName, notAnApiKeyMessage, } from "../profile_keys.js";
15
- import { noPassthrough, single } from "../utils/flags.js";
6
+ import { getApiClient, isNeonApiError } from "../api.js";
7
+ import { isInsideConfigDir } from "../_shared/paths.js";
8
+ import "../config.js";
9
+ import { setAuthContext } from "../auth_context.js";
10
+ import { auth, revokeToken } from "../auth.js";
11
+ import { assertProfilesUsable, assertValidProfileName, listProfiles, newProfileCredentialsPath, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, upsertProfile } from "../_shared/profiles.js";
12
+ import { credentialInputs } from "../_shared/auth_selection.js";
13
+ import { authFlow, credentialsPathForName, usableCredential } from "./auth.js";
16
14
  import { writer } from "../writer.js";
15
+ import { noPassthrough, single } from "../utils/flags.js";
17
16
  import { orgIdForProject } from "./api_keys.js";
18
- import { authFlow, credentialsPathForName, usableCredential } from "./auth.js";
19
- export const command = "profile";
20
- export const aliases = ["profiles"];
21
- export const describe = "Manage named sets of Neon credentials";
22
- export const builder = (argv) => argv
23
- .usage("$0 profile <sub-command> [options]")
24
- .command("list", "List profiles, the account each holds, and where its credentials live", (y) => y.strict().check(noPassthrough("profile list")), async (args) => await list(args))
25
- .command("create <name>", "Create a profile. It holds either a browser sign-in or an API key, never both", (y) => y
26
- .positional("name", {
27
- describe: "Name for the new profile",
28
- type: "string",
29
- demandOption: true,
30
- })
31
- .options({
32
- // `create` deliberately ignores NEON_API_KEY (see `resolveKeyToStore`),
33
- // but the global option renders "default: NEON_API_KEY" into this help
34
- // screen — so a user with it exported would expect it to be stored.
35
- "api-key": {
36
- describe: 'API key to store, or "-" to read it from stdin',
37
- defaultDescription: "none; this command ignores NEON_API_KEY",
38
- type: "string",
39
- // Force the next token to be taken as the value. Without it yargs
40
- // reads the `-` in `--api-key -` as an option of its own and reports
41
- // "Unknown command: -", so only the `=` form would bind.
42
- nargs: 1,
43
- },
44
- mint: {
45
- describe: "Sign in once in the browser, then store a freshly minted API key and nothing else",
46
- type: "boolean",
47
- default: false,
48
- },
49
- "org-id": {
50
- describe: "With --mint, mint a key for this organization instead of your account",
51
- type: "string",
52
- coerce: single("org-id"),
53
- },
54
- "project-id": {
55
- describe: "With --mint, mint a key that can access only this project",
56
- type: "string",
57
- coerce: single("project-id"),
58
- },
59
- force: {
60
- describe: "Replace an existing profile, revoking the credential it holds now",
61
- type: "boolean",
62
- default: false,
63
- },
64
- })
65
- // A project-scoped key is already an organization key, and its org is
66
- // derived from the project rather than chosen — same rule as `api-keys`.
67
- .conflicts("org-id", "project-id")
68
- .strict()
69
- .check(noPassthrough("profile create"))
70
- .example("$0 profile create work", "Sign in with the browser, like `neon auth --profile work`")
71
- .example('$0 profile create work --api-key "$KEY"', "Store a key you already have")
72
- .example('echo "$KEY" | $0 profile create work --api-key -', "Or pipe it, so it never reaches the process arguments")
73
- .example("$0 profile create ci --mint --org-id org-abc-123", "One browser sign-in, then an org-scoped key and no session left behind"), async (args) => await create(args))
74
- .command("rotate-key <name>", "Mint a fresh API key for a profile, at the same scope, and revoke the one it replaces", (y) => y
75
- .positional("name", {
76
- describe: "Profile to rotate",
77
- type: "string",
78
- demandOption: true,
79
- })
80
- .strict()
81
- .check(noPassthrough("profile rotate-key")), async (args) => await rotateKey(args))
82
- .command("remove <name>", "Revoke a profile's credential where possible, and remove it", (y) => y
83
- .positional("name", {
84
- describe: "Profile to remove",
85
- type: "string",
86
- demandOption: true,
87
- })
88
- .option("yes", {
89
- alias: "y",
90
- describe: "Skip the confirmation prompt",
91
- type: "boolean",
92
- default: false,
93
- })
94
- .strict()
95
- .check(noPassthrough("profile remove")), async (args) => await remove(args))
96
- .demandCommand(1, "Run `neon profile --help` to see the subcommands.");
97
- export const handler = (_args) => {
98
- /* subcommands only */
99
- };
17
+ import { identityFromAuthDetails, isApiKeyMethod, mintedKeyName, notAnApiKeyMessage } from "../profile_keys.js";
18
+ import { existsSync, readFileSync, rmSync } from "node:fs";
19
+ import { basename } from "node:path";
20
+ import prompts from "prompts";
21
+ //#region src/commands/profile.ts
22
+ var profile_exports = /* @__PURE__ */ __exportAll({
23
+ aliases: () => aliases,
24
+ builder: () => builder,
25
+ command: () => command,
26
+ describe: () => describe,
27
+ handler: () => handler
28
+ });
29
+ const command = "profile";
30
+ const aliases = ["profiles"];
31
+ const describe = "Manage named sets of Neon credentials";
32
+ const builder = (argv) => argv.usage("$0 profile <sub-command> [options]").command("list", "List profiles, the account each holds, and where its credentials live", (y) => y.strict().check(noPassthrough("profile list")), async (args) => await list(args)).command("create <name>", "Create a profile. It holds either a browser sign-in or an API key, never both", (y) => y.positional("name", {
33
+ describe: "Name for the new profile",
34
+ type: "string",
35
+ demandOption: true
36
+ }).options({
37
+ "api-key": {
38
+ describe: "API key to store, or \"-\" to read it from stdin",
39
+ defaultDescription: "none; this command ignores NEON_API_KEY",
40
+ type: "string",
41
+ nargs: 1
42
+ },
43
+ mint: {
44
+ describe: "Sign in once in the browser, then store a freshly minted API key and nothing else",
45
+ type: "boolean",
46
+ default: false
47
+ },
48
+ "org-id": {
49
+ describe: "With --mint, mint a key for this organization instead of your account",
50
+ type: "string",
51
+ coerce: single("org-id")
52
+ },
53
+ "project-id": {
54
+ describe: "With --mint, mint a key that can access only this project",
55
+ type: "string",
56
+ coerce: single("project-id")
57
+ },
58
+ force: {
59
+ describe: "Replace an existing profile, revoking the credential it holds now",
60
+ type: "boolean",
61
+ default: false
62
+ }
63
+ }).conflicts("org-id", "project-id").strict().check(noPassthrough("profile create")).example("$0 profile create work", "Sign in with the browser, like `neon auth --profile work`").example("$0 profile create work --api-key \"$KEY\"", "Store a key you already have").example("echo \"$KEY\" | $0 profile create work --api-key -", "Or pipe it, so it never reaches the process arguments").example("$0 profile create ci --mint --org-id org-abc-123", "One browser sign-in, then an org-scoped key and no session left behind"), async (args) => await create(args)).command("rotate-key <name>", "Mint a fresh API key for a profile, at the same scope, and revoke the one it replaces", (y) => y.positional("name", {
64
+ describe: "Profile to rotate",
65
+ type: "string",
66
+ demandOption: true
67
+ }).strict().check(noPassthrough("profile rotate-key")), async (args) => await rotateKey(args)).command("remove <name>", "Revoke a profile's credential where possible, and remove it", (y) => y.positional("name", {
68
+ describe: "Profile to remove",
69
+ type: "string",
70
+ demandOption: true
71
+ }).option("yes", {
72
+ alias: "y",
73
+ describe: "Skip the confirmation prompt",
74
+ type: "boolean",
75
+ default: false
76
+ }).strict().check(noPassthrough("profile remove")), async (args) => await remove(args)).demandCommand(1, "Run `neon profile --help` to see the subcommands.");
77
+ const handler = (_args) => {};
100
78
  /**
101
- * Refuse a global credential flag that a `profile` subcommand cannot honour.
102
- *
103
- * These commands name their target as a positional and authenticate as that profile, so both
104
- * `--profile` and `--api-key` have nothing to select here and were silently dropped — which is
105
- * precisely the class of bug this command family exists to stop. `.strict()` cannot catch
106
- * either, because both are global options and yargs has already accepted them.
107
- *
108
- * No "did you mean". The suggestion this used to make was built from the flag rather than the
109
- * positional, so it renamed the target: `profile remove work --profile other` answered "did you
110
- * mean `neon profile remove other`". Since the name is a required positional, that was wrong in
111
- * every case it fired, and on `remove` it was copy-pasteable destructive advice for an account
112
- * the user had not mentioned.
113
- */
79
+ * Refuse a global credential flag that a `profile` subcommand cannot honour.
80
+ *
81
+ * These commands name their target as a positional and authenticate as that profile, so both
82
+ * `--profile` and `--api-key` have nothing to select here and were silently dropped — which is
83
+ * precisely the class of bug this command family exists to stop. `.strict()` cannot catch
84
+ * either, because both are global options and yargs has already accepted them.
85
+ *
86
+ * No "did you mean". The suggestion this used to make was built from the flag rather than the
87
+ * positional, so it renamed the target: `profile remove work --profile other` answered "did you
88
+ * mean `neon profile remove other`". Since the name is a required positional, that was wrong in
89
+ * every case it fired, and on `remove` it was copy-pasteable destructive advice for an account
90
+ * the user had not mentioned.
91
+ */
114
92
  const rejectProfileFlag = (props, sub) => {
115
- const named = props.profile?.trim();
116
- if (!named)
117
- return;
118
- throw new Error(`--profile does not apply to \`profile ${sub}\`, which takes the profile name as an argument. You passed both "${props.name}" and --profile ${named}; drop --profile.`);
93
+ const named = props.profile?.trim();
94
+ if (!named) return;
95
+ throw new Error(`--profile does not apply to \`profile ${sub}\`, which takes the profile name as an argument. You passed both "${props.name}" and --profile ${named}; drop --profile.`);
119
96
  };
120
97
  /**
121
- * Refuse `--api-key` on a subcommand that authenticates as the profile it was given.
122
- *
123
- * The mirror image of the flag above, and it was the one left silent: `profile remove work
124
- * --api-key napi_…` ignored the key, used the stored one, and reported a revoke failure against
125
- * a credential the user had not passed. This PR exists because a dropped credential flag chose
126
- * the wrong account; leaving that unsaid on the commands that manage credentials would be the
127
- * same bug with the arguments swapped.
128
- */
98
+ * Refuse `--api-key` on a subcommand that authenticates as the profile it was given.
99
+ *
100
+ * The mirror image of the flag above, and it was the one left silent: `profile remove work
101
+ * --api-key napi_…` ignored the key, used the stored one, and reported a revoke failure against
102
+ * a credential the user had not passed. This PR exists because a dropped credential flag chose
103
+ * the wrong account; leaving that unsaid on the commands that manage credentials would be the
104
+ * same bug with the arguments swapped.
105
+ */
129
106
  const rejectApiKeyFlag = (sub, instead) => {
130
- if (credentialInputs().apiKeyFlag.trim() === "")
131
- return;
132
- throw new Error(`--api-key does not apply to \`profile ${sub}\`, which uses the credential the profile already holds.${instead ? ` ${instead}` : ""}`);
107
+ if (credentialInputs().apiKeyFlag.trim() === "") return;
108
+ throw new Error(`--api-key does not apply to \`profile ${sub}\`, which uses the credential the profile already holds.${instead ? ` ${instead}` : ""}`);
133
109
  };
134
110
  /**
135
- * What kind of credential a file holds, for display.
136
- *
137
- * A file that declares a kind we cannot read is reported as `invalid` with the reason logged,
138
- * rather than throwing: one broken profile must not hide every other row, and the whole point
139
- * of `list` is to show the state of things including the broken parts.
140
- */
111
+ * What kind of credential a file holds, for display.
112
+ *
113
+ * A file that declares a kind we cannot read is reported as `invalid` with the reason logged,
114
+ * rather than throwing: one broken profile must not hide every other row, and the whole point
115
+ * of `list` is to show the state of things including the broken parts.
116
+ */
141
117
  const describeAuth = (stored, at) => {
142
- if (stored === null)
143
- return "-";
144
- try {
145
- // `interpretCredentials`, not `credentialKind`: the kind is a declaration, and a file
146
- // declaring `api_key` with no key satisfies it. Reporting that as a working API-key
147
- // profile is the wrong answer for the one command run to find out what is broken.
148
- return interpretCredentials(stored, at).kind === API_KEY
149
- ? "api key"
150
- : "oauth";
151
- }
152
- catch (err) {
153
- log.warning(err instanceof Error ? err.message : String(err));
154
- return "invalid";
155
- }
118
+ if (stored === null) return "-";
119
+ try {
120
+ return interpretCredentials(stored, at).kind === "api_key" ? "api key" : "oauth";
121
+ } catch (err) {
122
+ log.warning(err instanceof Error ? err.message : String(err));
123
+ return "invalid";
124
+ }
156
125
  };
157
126
  /**
158
- * One row's view of a credentials file.
159
- *
160
- * Reports a damaged file as `invalid` and carries on, because one broken profile must not take
161
- * the whole listing with it — and unlike the authenticating path, listing has nothing to
162
- * recover, so the row itself is the report.
163
- *
164
- * One column answers "can this be used". A file whose JSON parses but whose `type` is
165
- * unreadable used to show `File: ok` beside `Auth: invalid`, so the column a user scans to find
166
- * the broken profile pointed at a different row than the one that was broken.
167
- */
127
+ * One row's view of a credentials file.
128
+ *
129
+ * Reports a damaged file as `invalid` and carries on, because one broken profile must not take
130
+ * the whole listing with it — and unlike the authenticating path, listing has nothing to
131
+ * recover, so the row itself is the report.
132
+ *
133
+ * One column answers "can this be used". A file whose JSON parses but whose `type` is
134
+ * unreadable used to show `File: ok` beside `Auth: invalid`, so the column a user scans to find
135
+ * the broken profile pointed at a different row than the one that was broken.
136
+ */
168
137
  const inspectForListing = (at) => {
169
- const read = inspectCredentials(at.path);
170
- if (read.kind === "unusable") {
171
- log.warning(read.reason);
172
- return { stored: null, auth: "-", file: "invalid" };
173
- }
174
- if (read.kind === "absent")
175
- return { stored: null, auth: "-", file: "missing" };
176
- const auth = describeAuth(read.credentials, at);
177
- return {
178
- stored: read.credentials,
179
- auth,
180
- file: auth === "invalid" ? "invalid" : "ok",
181
- };
138
+ const read = inspectCredentials(at.path);
139
+ if (read.kind === "unusable") {
140
+ log.warning(read.reason);
141
+ return {
142
+ stored: null,
143
+ auth: "-",
144
+ file: "invalid"
145
+ };
146
+ }
147
+ if (read.kind === "absent") return {
148
+ stored: null,
149
+ auth: "-",
150
+ file: "missing"
151
+ };
152
+ const auth = describeAuth(read.credentials, at);
153
+ return {
154
+ stored: read.credentials,
155
+ auth,
156
+ file: auth === "invalid" ? "invalid" : "ok"
157
+ };
182
158
  };
183
159
  const list = async (props) => {
184
- // Reading files on disk; a key would authenticate nothing here.
185
- rejectApiKeyFlag("list");
186
- // `list` is the one subcommand where `--profile` means something: it marks the active row.
187
- // It still has to be a profile that exists, or the marker silently lands on nothing.
188
- const active = selectProfileName(props.profile);
189
- if (props.profile !== undefined && props.profile.trim() !== "") {
190
- resolveProfile(props.configDir, active);
191
- }
192
- const rows = listProfiles(props.configDir).map((p) => {
193
- const { stored, auth, file } = inspectForListing({
194
- path: p.credentialsPath,
195
- profile: p.name,
196
- });
197
- const storedUserId = typeof stored?.user_id === "string" ? stored.user_id : undefined;
198
- return {
199
- active: p.name === active ? "*" : "",
200
- name: p.name,
201
- account: p.label ?? p.userId ?? storedUserId ?? "-",
202
- auth,
203
- // Only a key carries a scope. An OAuth session reaches whatever its account does.
204
- scope: stored !== null && auth === "api key"
205
- ? describeScope(scopeOf(stored))
206
- : "-",
207
- // Names what was actually checked. The column this replaced was called "available",
208
- // which claims the credential is ready to use — a thing reading a file cannot show,
209
- // and the wrong answer for the dead key someone runs this to diagnose.
210
- file,
211
- // The basename in the table, because `cli-table` neither wraps nor truncates and a
212
- // real path pushes the row past most terminals. Structured output keeps the path,
213
- // which is what a script actually wants.
214
- ...(props.output === "table"
215
- ? { credentials: basename(p.credentialsPath) }
216
- : { credentials: p.credentialsPath }),
217
- };
218
- });
219
- writer(props).end(rows, {
220
- title: "Profiles",
221
- fields: [
222
- "active",
223
- "name",
224
- "account",
225
- "auth",
226
- "scope",
227
- "file",
228
- "credentials",
229
- ],
230
- });
160
+ rejectApiKeyFlag("list");
161
+ const active = selectProfileName(props.profile);
162
+ if (props.profile !== void 0 && props.profile.trim() !== "") resolveProfile(props.configDir, active);
163
+ const rows = listProfiles(props.configDir).map((p) => {
164
+ const { stored, auth, file } = inspectForListing({
165
+ path: p.credentialsPath,
166
+ profile: p.name
167
+ });
168
+ const storedUserId = typeof stored?.user_id === "string" ? stored.user_id : void 0;
169
+ return {
170
+ active: p.name === active ? "*" : "",
171
+ name: p.name,
172
+ account: p.label ?? p.userId ?? storedUserId ?? "-",
173
+ auth,
174
+ scope: stored !== null && auth === "api key" ? describeScope(scopeOf(stored)) : "-",
175
+ file,
176
+ ...props.output === "table" ? { credentials: basename(p.credentialsPath) } : { credentials: p.credentialsPath }
177
+ };
178
+ });
179
+ writer(props).end(rows, {
180
+ title: "Profiles",
181
+ fields: [
182
+ "active",
183
+ "name",
184
+ "account",
185
+ "auth",
186
+ "scope",
187
+ "file",
188
+ "credentials"
189
+ ]
190
+ });
231
191
  };
232
192
  /**
233
- * The credential a command is about to replace or delete.
234
- *
235
- * Deliberately tolerant where {@link readCredentials} is fatal. Using a damaged credential must
236
- * fail loudly, but *replacing* one must not: `readCredentials` throwing here made the repair its
237
- * own error message recommends impossible, and left a malformed file unremovable through the
238
- * CLI. There is nothing to revoke in a file we cannot parse, so this says so and moves on.
239
- */
193
+ * The credential a command is about to replace or delete.
194
+ *
195
+ * Deliberately tolerant where {@link readCredentials} is fatal. Using a damaged credential must
196
+ * fail loudly, but *replacing* one must not: `readCredentials` throwing here made the repair its
197
+ * own error message recommends impossible, and left a malformed file unremovable through the
198
+ * CLI. There is nothing to revoke in a file we cannot parse, so this says so and moves on.
199
+ */
240
200
  const readOutgoingCredential = (at) => {
241
- const read = inspectCredentials(at.path);
242
- const unusable = (reason) => {
243
- // `reason` comes from several sources, some of which end in a period. Trim it so the
244
- // sentence does not run "…cannot be read.. Nothing in it…".
245
- log.warning("%s. Nothing in it could be revoked, so it is only being replaced locally.", reason.replace(/\.$/, ""));
246
- return null;
247
- };
248
- if (read.kind === "unusable")
249
- return unusable(read.reason);
250
- if (read.kind === "absent")
251
- return null;
252
- // Interpreted, not merely classified. A file declaring `api_key` with no key satisfies
253
- // `credentialKind` and would then reach `getApiClient` with `undefined` behind a cast — a
254
- // revoke request authenticated by nothing, reported as a failed revocation. There is
255
- // genuinely nothing to revoke here, which is what `unusable` says; and saying it rather
256
- // than throwing is what keeps the file removable.
257
- try {
258
- const credential = interpretCredentials(read.credentials, at);
259
- if (credential.kind === OAUTH) {
260
- return { kind: OAUTH, tokens: read.credentials };
261
- }
262
- return {
263
- kind: API_KEY,
264
- apiKey: credential.apiKey,
265
- ...(typeof read.credentials.key_id === "number"
266
- ? { keyId: read.credentials.key_id }
267
- : {}),
268
- scope: scopeOf(read.credentials),
269
- };
270
- }
271
- catch (err) {
272
- return unusable(err instanceof Error ? err.message : String(err));
273
- }
201
+ const read = inspectCredentials(at.path);
202
+ const unusable = (reason) => {
203
+ log.warning("%s. Nothing in it could be revoked, so it is only being replaced locally.", reason.replace(/\.$/, ""));
204
+ return null;
205
+ };
206
+ if (read.kind === "unusable") return unusable(read.reason);
207
+ if (read.kind === "absent") return null;
208
+ try {
209
+ const credential = interpretCredentials(read.credentials, at);
210
+ if (credential.kind === "oauth") return {
211
+ kind: OAUTH,
212
+ tokens: read.credentials
213
+ };
214
+ return {
215
+ kind: API_KEY,
216
+ apiKey: credential.apiKey,
217
+ ...typeof read.credentials.key_id === "number" ? { keyId: read.credentials.key_id } : {},
218
+ scope: scopeOf(read.credentials)
219
+ };
220
+ } catch (err) {
221
+ return unusable(err instanceof Error ? err.message : String(err));
222
+ }
274
223
  };
275
224
  const credentialsPathFor = (configDir, name) => {
276
- // Metadata we cannot read makes this a guess: `credentials.<name>.json` is a convention,
277
- // and the entry that would say whether it is this account's file is exactly what is
278
- // unreadable. Guessing here is what let a replacement overwrite a file and revoke its key
279
- // before `upsertProfile` refused the same metadata.
280
- assertProfilesUsable(configDir, name);
281
- if (readProfiles(configDir)?.profiles[name] || name === DEFAULT_PROFILE) {
282
- return credentialsPathForName(configDir, name);
283
- }
284
- return newProfileCredentialsPath(configDir, name);
225
+ assertProfilesUsable(configDir, name);
226
+ if (readProfiles(configDir)?.profiles[name] || name === "DEFAULT") return credentialsPathForName(configDir, name);
227
+ return newProfileCredentialsPath(configDir, name);
285
228
  };
286
229
  /**
287
- * Refuse to overwrite an existing profile unless asked to, and say what `--force` destroys.
288
- *
289
- * Naming the consequence is the point. `--force` does not merely point the profile somewhere
290
- * else: {@link retirePreviousCredential} revokes the credential being replaced, so a key this
291
- * CLI minted dies upstream — including the copy the user pasted into CI or another machine,
292
- * which is not recoverable, only re-mintable. A message promising "replace its credential"
293
- * described a local edit and made the irreversible half a surprise.
294
- */
230
+ * Refuse to overwrite an existing profile unless asked to, and say what `--force` destroys.
231
+ *
232
+ * Naming the consequence is the point. `--force` does not merely point the profile somewhere
233
+ * else: {@link retirePreviousCredential} revokes the credential being replaced, so a key this
234
+ * CLI minted dies upstream — including the copy the user pasted into CI or another machine,
235
+ * which is not recoverable, only re-mintable. A message promising "replace its credential"
236
+ * described a local edit and made the irreversible half a surprise.
237
+ */
295
238
  const assertReplaceable = (props) => {
296
- const { name, configDir, force } = props;
297
- if (force)
298
- return;
299
- const declared = readProfiles(configDir)?.profiles[name];
300
- const path = credentialsPathFor(configDir, name);
301
- if (!declared && !(name === DEFAULT_PROFILE && existsSync(path)))
302
- return;
303
- const existing = inspectCredentials(path);
304
- const stored = existing.kind === "ok" ? existing.credentials : null;
305
- // Only offer `rotate-key` when it would actually work: it refuses anything that is not
306
- // already an API-key profile, and `DEFAULT` is an OAuth profile on nearly every install.
307
- const holdsKey = stored !== null &&
308
- credentialKind(stored, { path, profile: name }) === API_KEY;
309
- const keyId = typeof stored?.key_id === "number" ? stored.key_id : undefined;
310
- if (holdsKey) {
311
- throw new Error(keyId !== undefined
312
- ? `Profile "${name}" already exists and holds an API key minted here (id ${keyId}). Pass --force to replace it — the key is revoked, wherever else it is in use. To keep the profile and swap the key instead: \`neon profile rotate-key ${name}\`.`
313
- : `Profile "${name}" already exists and holds an API key you supplied, which stays live on the account either way — only keys minted here record an id to revoke. Pass --force to replace it locally, or \`neon profile rotate-key ${name}\` to mint one at the same scope.`);
314
- }
315
- throw new Error(`Profile "${name}" already exists and holds a browser sign-in. Pass --force to replace it — the session is signed out as part of the replacement.`);
239
+ const { name, configDir, force } = props;
240
+ if (force) return;
241
+ const declared = readProfiles(configDir)?.profiles[name];
242
+ const path = credentialsPathFor(configDir, name);
243
+ if (!declared && !(name === "DEFAULT" && existsSync(path))) return;
244
+ const existing = inspectCredentials(path);
245
+ const stored = existing.kind === "ok" ? existing.credentials : null;
246
+ const holdsKey = stored !== null && credentialKind(stored, {
247
+ path,
248
+ profile: name
249
+ }) === "api_key";
250
+ const keyId = typeof stored?.key_id === "number" ? stored.key_id : void 0;
251
+ if (holdsKey) throw new Error(keyId !== void 0 ? `Profile "${name}" already exists and holds an API key minted here (id ${keyId}). Pass --force to replace it — the key is revoked, wherever else it is in use. To keep the profile and swap the key instead: \`neon profile rotate-key ${name}\`.` : `Profile "${name}" already exists and holds an API key you supplied, which stays live on the account either way — only keys minted here record an id to revoke. Pass --force to replace it locally, or \`neon profile rotate-key ${name}\` to mint one at the same scope.`);
252
+ throw new Error(`Profile "${name}" already exists and holds a browser sign-in. Pass --force to replace it — the session is signed out as part of the replacement.`);
316
253
  };
317
254
  /** `--api-key -` means "read it from stdin", the usual convention for a piped value. */
318
255
  const STDIN = "-";
319
256
  /**
320
- * The key to store.
321
- *
322
- * Read from the *flag* and never from `NEON_API_KEY`. Everywhere else those are
323
- * interchangeable, but "store this credential permanently" is not something an exported
324
- * environment variable should be able to answer — a `create` that silently wrote whatever
325
- * happened to be in the shell would be storing a key the user never named.
326
- *
327
- * One flag, deliberately. Earlier revisions added `--api-key-file`, `--api-key-stdin` and
328
- * `--api-key-prompt` to keep the secret out of argv, which is four surfaces to document and
329
- * validate against each other for things the shell already does — `--api-key "$(cat file)"`
330
- * reads a file, `--api-key "$KEY"` takes a variable. Piping is the one case the shell cannot
331
- * express through an argument, so it gets the usual convention instead of a flag: `-`.
332
- */
257
+ * The key to store.
258
+ *
259
+ * Read from the *flag* and never from `NEON_API_KEY`. Everywhere else those are
260
+ * interchangeable, but "store this credential permanently" is not something an exported
261
+ * environment variable should be able to answer — a `create` that silently wrote whatever
262
+ * happened to be in the shell would be storing a key the user never named.
263
+ *
264
+ * One flag, deliberately. Earlier revisions added `--api-key-file`, `--api-key-stdin` and
265
+ * `--api-key-prompt` to keep the secret out of argv, which is four surfaces to document and
266
+ * validate against each other for things the shell already does — `--api-key "$(cat file)"`
267
+ * reads a file, `--api-key "$KEY"` takes a variable. Piping is the one case the shell cannot
268
+ * express through an argument, so it gets the usual convention instead of a flag: `-`.
269
+ */
333
270
  const resolveKeyToStore = (props) => {
334
- const fromFlag = credentialInputs().apiKeyFlag.trim();
335
- if (fromFlag === STDIN)
336
- return readKeyFromStdin();
337
- if (fromFlag !== "")
338
- return fromFlag;
339
- throw new Error(`Nothing to store for profile "${props.name}". Pass --api-key (or --api-key - to pipe it), --mint to have one minted, or no flags at all to sign in with the browser.`);
271
+ const fromFlag = credentialInputs().apiKeyFlag.trim();
272
+ if (fromFlag === STDIN) return readKeyFromStdin();
273
+ if (fromFlag !== "") return fromFlag;
274
+ throw new Error(`Nothing to store for profile "${props.name}". Pass --api-key (or --api-key - to pipe it), --mint to have one minted, or no flags at all to sign in with the browser.`);
340
275
  };
341
276
  /**
342
- * Read the key from stdin, for `--api-key -`.
343
- *
344
- * A terminal is refused rather than read: `readFileSync(0)` on a tty waits for EOF with no
345
- * prompt and nothing echoed, which is indistinguishable from a hang — and this spelling exists
346
- * for pipes, so a terminal means the pipe is missing.
347
- */
277
+ * Read the key from stdin, for `--api-key -`.
278
+ *
279
+ * A terminal is refused rather than read: `readFileSync(0)` on a tty waits for EOF with no
280
+ * prompt and nothing echoed, which is indistinguishable from a hang — and this spelling exists
281
+ * for pipes, so a terminal means the pipe is missing.
282
+ */
348
283
  const readKeyFromStdin = () => {
349
- if (process.stdin.isTTY) {
350
- throw new Error('`--api-key -` reads the key from stdin, but stdin is a terminal. Pipe it in: echo "$KEY" | neon profile create <name> --api-key -');
351
- }
352
- const piped = readFileSync(0, "utf8").trim();
353
- if (piped === "") {
354
- throw new Error("Nothing arrived on stdin, so there is no API key to store.");
355
- }
356
- return piped;
284
+ if (process.stdin.isTTY) throw new Error("`--api-key -` reads the key from stdin, but stdin is a terminal. Pipe it in: echo \"$KEY\" | neon profile create <name> --api-key -");
285
+ const piped = readFileSync(0, "utf8").trim();
286
+ if (piped === "") throw new Error("Nothing arrived on stdin, so there is no API key to store.");
287
+ return piped;
357
288
  };
358
289
  /**
359
- * Confirm a key works and find out whose it is, before it is written anywhere.
360
- *
361
- * `getAuthDetails` rather than `GET /users/me`: an organization key gets `404 not allowed for
362
- * organization API keys` from the latter, so asking would fail a perfectly good key.
363
- */
364
- const verifyKey = async (props, apiKey,
365
- /** Where the key came from, so a rejection can say what to do about it. */
366
- origin = "supplied") => {
367
- const apiClient = getApiClient({ apiKey, apiHost: props.apiHost });
368
- const { data: details } = await apiClient
369
- .getAuthDetails()
370
- .catch((err) => {
371
- // `profile` skips `ensureAuth`, so nothing recorded how this call authenticated and
372
- // the top-level handler falls back to "Check --api-key or NEON_API_KEY" — while
373
- // this command's own help says it ignores `NEON_API_KEY`. An agent reads that and
374
- // exports the variable to no effect.
375
- if (isNeonApiError(err) && err.status === 401) {
376
- throw new Error(origin === "minted"
377
- ? "Neon rejected the key it had just minted. Nothing was stored; the key is being revoked."
378
- : "The Neon API rejected the key passed to --api-key. Check the value, or use --mint to have one minted for this profile.");
379
- }
380
- throw err;
381
- });
382
- if (!isApiKeyMethod(details.auth_method)) {
383
- throw new Error(notAnApiKeyMessage(details.auth_method));
384
- }
385
- // Only a user key has a user to look up: `GET /users/me` answers 404 for an organization
386
- // key, so asking anyway would fail a `create` that is working perfectly.
387
- const email = details.auth_method === "api_key_user"
388
- ? (await apiClient.getCurrentUserInfo()).data.email
389
- : undefined;
390
- return {
391
- ...identityFromAuthDetails(details, email),
392
- ...(details.auth_method === "api_key_org"
393
- ? { orgId: details.account_id }
394
- : {}),
395
- apiClient,
396
- };
290
+ * Confirm a key works and find out whose it is, before it is written anywhere.
291
+ *
292
+ * `getAuthDetails` rather than `GET /users/me`: an organization key gets `404 not allowed for
293
+ * organization API keys` from the latter, so asking would fail a perfectly good key.
294
+ */
295
+ const verifyKey = async (props, apiKey, origin = "supplied") => {
296
+ const apiClient = getApiClient({
297
+ apiKey,
298
+ apiHost: props.apiHost
299
+ });
300
+ const { data: details } = await apiClient.getAuthDetails().catch((err) => {
301
+ if (isNeonApiError(err) && err.status === 401) throw new Error(origin === "minted" ? "Neon rejected the key it had just minted. Nothing was stored; the key is being revoked." : "The Neon API rejected the key passed to --api-key. Check the value, or use --mint to have one minted for this profile.");
302
+ throw err;
303
+ });
304
+ if (!isApiKeyMethod(details.auth_method)) throw new Error(notAnApiKeyMessage(details.auth_method));
305
+ const email = details.auth_method === "api_key_user" ? (await apiClient.getCurrentUserInfo()).data.email : void 0;
306
+ return {
307
+ ...identityFromAuthDetails(details, email),
308
+ ...details.auth_method === "api_key_org" ? { orgId: details.account_id } : {},
309
+ apiClient
310
+ };
397
311
  };
398
312
  const create = async (props) => {
399
- const { name } = props;
400
- rejectProfileFlag(props, "create");
401
- assertValidProfileName(name);
402
- // Before the key is read from stdin, verified against the API, or minted in a browser.
403
- assertProfilesUsable(props.configDir, name);
404
- assertReplaceable(props);
405
- const suppliedKey = credentialInputs().apiKeyFlag.trim() !== "";
406
- if (props.mint) {
407
- // Silently preferring one over the other would store a credential the user did not
408
- // choose — and the two disagree about which account the profile ends up as.
409
- if (suppliedKey) {
410
- throw new Error("--mint creates a new key, so it cannot be combined with --api-key. Drop one.");
411
- }
412
- await createByMinting(props);
413
- return;
414
- }
415
- if ((props.orgId ?? props.projectId) !== undefined) {
416
- throw new Error("--org-id and --project-id only apply with --mint: they choose what a key we mint can reach, and a key you supply already has a scope.");
417
- }
418
- // No key and no --mint means a browser sign-in, which is exactly `neon auth --profile`.
419
- // Delegating rather than reimplementing keeps one OAuth path in the CLI.
420
- if (!suppliedKey) {
421
- // The no-flag form is the one an agent reaches for first, and `authFlow` answers it
422
- // with a bare "Cannot run interactive auth in CI" — true, and no way forward. Say the
423
- // same thing `--mint` says, since the two ways out are the same.
424
- if (isCi() && props.forceAuth !== true) {
425
- throw new Error(`\`neon profile create ${name}\` with no key signs in through the browser, which cannot happen in CI. Pass a key instead: \`neon profile create ${name} --api-key "$KEY"\`, or pipe it with \`echo "$KEY" | neon profile create ${name} --api-key -\`.`);
426
- }
427
- const credentialsPath = credentialsPathFor(props.configDir, name);
428
- const previous = readOutgoingCredential({
429
- path: credentialsPath,
430
- profile: name,
431
- });
432
- // `authFlow` reports its own failure and returns an empty token rather than throwing,
433
- // so claiming success here would leave the user with no profile and no error.
434
- if ((await authFlow({ ...props, _: ["auth"], profile: name })) === "") {
435
- throw new Error(`Could not save credentials for profile "${name}".`);
436
- }
437
- await retirePreviousCredential(props, name, previous);
438
- const signedIn = readProfiles(props.configDir, log.warning)?.profiles[name];
439
- report(props, {
440
- name,
441
- account: signedIn?.label ?? signedIn?.userId ?? "unknown account",
442
- auth: "oauth",
443
- scope: "-",
444
- credentials: credentialsPath,
445
- });
446
- return;
447
- }
448
- const apiKey = resolveKeyToStore(props);
449
- const identity = await verifyKey(props, apiKey);
450
- const credentialsPath = credentialsPathFor(props.configDir, name);
451
- const previous = readOutgoingCredential({
452
- path: credentialsPath,
453
- profile: name,
454
- });
455
- writeCredentials(credentialsPath, apiKeyCredentials({
456
- apiKey,
457
- ...(identity.userId !== undefined
458
- ? { userId: identity.userId }
459
- : {}),
460
- // A supplied organization key does not say which project it reaches, but the API
461
- // does say which organization — recording it keeps `list` from claiming "account",
462
- // and lets a later rotation refuse on the right grounds.
463
- ...(identity.orgId !== undefined
464
- ? { scope: { orgId: identity.orgId } }
465
- : {}),
466
- }));
467
- await retirePreviousCredential(props, name, previous, apiKey);
468
- recordProfile(props, name, credentialsPath, identity);
469
- report(props, {
470
- name,
471
- account: identity.label ?? "unknown account",
472
- auth: "api key",
473
- scope: describeScope(identity.orgId !== undefined ? { orgId: identity.orgId } : {}),
474
- credentials: credentialsPath,
475
- });
476
- // A key we did not mint has no discoverable id: `GET /api_keys` exposes no prefix, so a
477
- // stored secret cannot be matched back to a listing entry. A warning rather than a note,
478
- // because it is the same caveat `api-keys create` raises at that level.
479
- log.warning("A key you supplied cannot be revoked by `rotate-key` or `remove` — only keys minted here record an id.");
313
+ const { name } = props;
314
+ rejectProfileFlag(props, "create");
315
+ assertValidProfileName(name);
316
+ assertProfilesUsable(props.configDir, name);
317
+ assertReplaceable(props);
318
+ const suppliedKey = credentialInputs().apiKeyFlag.trim() !== "";
319
+ if (props.mint) {
320
+ if (suppliedKey) throw new Error("--mint creates a new key, so it cannot be combined with --api-key. Drop one.");
321
+ await createByMinting(props);
322
+ return;
323
+ }
324
+ if ((props.orgId ?? props.projectId) !== void 0) throw new Error("--org-id and --project-id only apply with --mint: they choose what a key we mint can reach, and a key you supply already has a scope.");
325
+ if (!suppliedKey) {
326
+ if (isCi() && props.forceAuth !== true) throw new Error(`\`neon profile create ${name}\` with no key signs in through the browser, which cannot happen in CI. Pass a key instead: \`neon profile create ${name} --api-key "$KEY"\`, or pipe it with \`echo "$KEY" | neon profile create ${name} --api-key -\`.`);
327
+ const credentialsPath = credentialsPathFor(props.configDir, name);
328
+ const previous = readOutgoingCredential({
329
+ path: credentialsPath,
330
+ profile: name
331
+ });
332
+ if (await authFlow({
333
+ ...props,
334
+ _: ["auth"],
335
+ profile: name
336
+ }) === "") throw new Error(`Could not save credentials for profile "${name}".`);
337
+ await retirePreviousCredential(props, name, previous);
338
+ const signedIn = readProfiles(props.configDir, log.warning)?.profiles[name];
339
+ report(props, {
340
+ name,
341
+ account: signedIn?.label ?? signedIn?.userId ?? "unknown account",
342
+ auth: "oauth",
343
+ scope: "-",
344
+ credentials: credentialsPath
345
+ });
346
+ return;
347
+ }
348
+ const apiKey = resolveKeyToStore(props);
349
+ const identity = await verifyKey(props, apiKey);
350
+ const credentialsPath = credentialsPathFor(props.configDir, name);
351
+ const previous = readOutgoingCredential({
352
+ path: credentialsPath,
353
+ profile: name
354
+ });
355
+ writeCredentials(credentialsPath, apiKeyCredentials({
356
+ apiKey,
357
+ ...identity.userId !== void 0 ? { userId: identity.userId } : {},
358
+ ...identity.orgId !== void 0 ? { scope: { orgId: identity.orgId } } : {}
359
+ }));
360
+ await retirePreviousCredential(props, name, previous, apiKey);
361
+ recordProfile(props, name, credentialsPath, identity);
362
+ report(props, {
363
+ name,
364
+ account: identity.label ?? "unknown account",
365
+ auth: "api key",
366
+ scope: describeScope(identity.orgId !== void 0 ? { orgId: identity.orgId } : {}),
367
+ credentials: credentialsPath
368
+ });
369
+ log.warning("A key you supplied cannot be revoked by `rotate-key` or `remove` — only keys minted here record an id.");
480
370
  };
481
371
  /**
482
- * What a minted key can reach, in the same words `neon api-keys create` uses for the same key.
483
- *
484
- * Saying nothing here would be worse than there: this is the command aimed at agents and shared
485
- * machines, which is exactly where an over-broad credential does the most damage.
486
- */
372
+ * What a minted key can reach, in the same words `neon api-keys create` uses for the same key.
373
+ *
374
+ * Saying nothing here would be worse than there: this is the command aimed at agents and shared
375
+ * machines, which is exactly where an over-broad credential does the most damage.
376
+ */
487
377
  const warnAboutReach = (scope) => {
488
- if (scope.projectId !== undefined) {
489
- log.info("Limited to %s: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.", scope.projectId);
490
- return;
491
- }
492
- if (scope.orgId !== undefined) {
493
- log.warning("This key reaches every project in %s, including ones created later. Pass --project-id instead to restrict it to one.", scope.orgId);
494
- return;
495
- }
496
- log.warning("This key reaches everything your account can, in every organization. Pass --org-id or --project-id to narrow it.");
378
+ if (scope.projectId !== void 0) {
379
+ log.info("Limited to %s: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.", scope.projectId);
380
+ return;
381
+ }
382
+ if (scope.orgId !== void 0) {
383
+ log.warning("This key reaches every project in %s, including ones created later. Pass --project-id instead to restrict it to one.", scope.orgId);
384
+ return;
385
+ }
386
+ log.warning("This key reaches everything your account can, in every organization. Pass --org-id or --project-id to narrow it.");
497
387
  };
498
388
  /**
499
- * Report a profile that was just written.
500
- *
501
- * Through the writer rather than `log`, so `--output json` yields the record an agent needs
502
- * instead of nothing — it had to follow up with `profile list` to learn what it just created.
503
- */
389
+ * Report a profile that was just written.
390
+ *
391
+ * Through the writer rather than `log`, so `--output json` yields the record an agent needs
392
+ * instead of nothing — it had to follow up with `profile list` to learn what it just created.
393
+ */
504
394
  const report = (props, record) => {
505
- const out = writer(props);
506
- if (props.output === "table") {
507
- log.info('Profile "%s" now holds %s for %s (%s), in %s', record.name, record.auth, record.account, record.scope, record.credentials);
508
- return;
509
- }
510
- out.end(record, {
511
- fields: [
512
- "name",
513
- "account",
514
- "auth",
515
- "scope",
516
- ...(record.keyId !== undefined ? ["keyId"] : []),
517
- "credentials",
518
- ],
519
- title: "Profile",
520
- });
395
+ const out = writer(props);
396
+ if (props.output === "table") {
397
+ log.info("Profile \"%s\" now holds %s for %s (%s), in %s", record.name, record.auth, record.account, record.scope, record.credentials);
398
+ return;
399
+ }
400
+ out.end(record, {
401
+ fields: [
402
+ "name",
403
+ "account",
404
+ "auth",
405
+ "scope",
406
+ ...record.keyId !== void 0 ? ["keyId"] : [],
407
+ "credentials"
408
+ ],
409
+ title: "Profile"
410
+ });
521
411
  };
522
412
  const recordProfile = (props, name, credentialsPath, identity) => {
523
- upsertProfile(props.configDir, name, {
524
- credentials: credentialsPath,
525
- ...(identity.label ? { label: identity.label } : {}),
526
- ...(identity.userId ? { userId: identity.userId } : {}),
527
- });
413
+ upsertProfile(props.configDir, name, {
414
+ credentials: credentialsPath,
415
+ ...identity.label ? { label: identity.label } : {},
416
+ ...identity.userId ? { userId: identity.userId } : {}
417
+ });
528
418
  };
529
419
  /**
530
- * Sign in once, mint a key, keep only the key.
531
- *
532
- * This is the reason to prefer a key-backed profile at all: after it, nothing about the profile
533
- * can trigger a browser. The OAuth session is a means to an end and is revoked at the end of
534
- * it, so no half-forgotten login is left behind — which also means the file holds exactly one
535
- * credential, for exactly one account.
536
- */
420
+ * Sign in once, mint a key, keep only the key.
421
+ *
422
+ * This is the reason to prefer a key-backed profile at all: after it, nothing about the profile
423
+ * can trigger a browser. The OAuth session is a means to an end and is revoked at the end of
424
+ * it, so no half-forgotten login is left behind — which also means the file holds exactly one
425
+ * credential, for exactly one account.
426
+ */
537
427
  const createByMinting = async (props) => {
538
- const { name } = props;
539
- // `authFlow` refuses to open a browser in CI; minting calls `auth` directly and so has to
540
- // make the same check itself, or this would sit waiting for a login nobody can complete.
541
- if (isCi() && props.forceAuth !== true) {
542
- throw new Error(`--mint needs a browser sign-in, which cannot happen in CI. Mint the key with \`neon api-keys create --name ${name}\` and pipe it in: echo "$KEY" | neon profile create ${name} --api-key -`);
543
- }
544
- const oauthProps = {
545
- oauthHost: props.oauthHost,
546
- clientId: props.clientId,
547
- ...(props.allowUnsafeTls
548
- ? { allowUnsafeTls: props.allowUnsafeTls }
549
- : {}),
550
- };
551
- // Sign in before anything else that needs an API call. `profile` commands skip
552
- // `ensureAuth`, so this invocation has no API client of its own — the session minted here
553
- // is the only credential available, and `--project-id` needs one to find the owning org.
554
- const tokenSet = await auth(oauthProps);
555
- const session = getApiClient({
556
- apiKey: tokenSet.access_token ?? "",
557
- apiHost: props.apiHost,
558
- });
559
- // Whatever happens after this point, the session was a means to an end. Revoking it in a
560
- // `finally` is what makes "stores only the key" true even when the command fails partway.
561
- let minted;
562
- // The scope actually minted at, not the flags: `--project-id` resolves an organization that
563
- // the revoke endpoint needs, and rebuilding it from flags would withdraw a project key
564
- // through the account endpoint and leave it live.
565
- let mintedScope = {};
566
- let keyIsReachable = false;
567
- try {
568
- const scope = await resolveMintScope(props, session);
569
- mintedScope = scope;
570
- minted = await mintKey(session, name, scope);
571
- const identity = await verifyKey(props, minted.key, "minted");
572
- const credentialsPath = credentialsPathFor(props.configDir, name);
573
- const previous = readOutgoingCredential({
574
- path: credentialsPath,
575
- profile: name,
576
- });
577
- writeCredentials(credentialsPath, apiKeyCredentials({
578
- apiKey: minted.key,
579
- keyId: minted.id,
580
- ...(identity.userId !== undefined
581
- ? { userId: identity.userId }
582
- : {}),
583
- scope,
584
- }));
585
- // From here the key is on disk and usable, so cleanup must not take it away — a
586
- // failure writing `profiles.json` costs a profile entry, not the credential.
587
- keyIsReachable = true;
588
- await retirePreviousCredential(props, name, previous, minted.key);
589
- recordProfile(props, name, credentialsPath, identity);
590
- report(props, {
591
- name,
592
- account: identity.label ?? "unknown account",
593
- auth: "api key",
594
- scope: describeScope(scope),
595
- keyId: minted.id,
596
- credentials: credentialsPath,
597
- });
598
- warnAboutReach(scope);
599
- }
600
- finally {
601
- // A key minted but never written is unreachable, so it must not be left live.
602
- if (minted !== undefined && !keyIsReachable) {
603
- log.info((await withdrawKey(session, mintedScope, minted.id))
604
- ? `Revoked the key that was minted but not stored (id ${minted.id}).`
605
- : `Minted key ${minted.id} could be neither stored nor revoked, and may still be live. Remove it with: neon api-keys revoke ${minted.id}${mintedScope.orgId ? ` --org-id ${mintedScope.orgId}` : ""}`);
606
- }
607
- const revoked = await revokeToken(oauthProps, tokenSet);
608
- log.info(revoked
609
- ? "Signed the browser session back out, so nothing but the key remains."
610
- : "Could not sign the browser session back out; it will expire on its own.");
611
- }
428
+ const { name } = props;
429
+ if (isCi() && props.forceAuth !== true) throw new Error(`--mint needs a browser sign-in, which cannot happen in CI. Mint the key with \`neon api-keys create --name ${name}\` and pipe it in: echo "$KEY" | neon profile create ${name} --api-key -`);
430
+ const oauthProps = {
431
+ oauthHost: props.oauthHost,
432
+ clientId: props.clientId,
433
+ ...props.allowUnsafeTls ? { allowUnsafeTls: props.allowUnsafeTls } : {}
434
+ };
435
+ const tokenSet = await auth(oauthProps);
436
+ const session = getApiClient({
437
+ apiKey: tokenSet.access_token ?? "",
438
+ apiHost: props.apiHost
439
+ });
440
+ let minted;
441
+ let mintedScope = {};
442
+ let keyIsReachable = false;
443
+ try {
444
+ const scope = await resolveMintScope(props, session);
445
+ mintedScope = scope;
446
+ minted = await mintKey(session, name, scope);
447
+ const identity = await verifyKey(props, minted.key, "minted");
448
+ const credentialsPath = credentialsPathFor(props.configDir, name);
449
+ const previous = readOutgoingCredential({
450
+ path: credentialsPath,
451
+ profile: name
452
+ });
453
+ writeCredentials(credentialsPath, apiKeyCredentials({
454
+ apiKey: minted.key,
455
+ keyId: minted.id,
456
+ ...identity.userId !== void 0 ? { userId: identity.userId } : {},
457
+ scope
458
+ }));
459
+ keyIsReachable = true;
460
+ await retirePreviousCredential(props, name, previous, minted.key);
461
+ recordProfile(props, name, credentialsPath, identity);
462
+ report(props, {
463
+ name,
464
+ account: identity.label ?? "unknown account",
465
+ auth: "api key",
466
+ scope: describeScope(scope),
467
+ keyId: minted.id,
468
+ credentials: credentialsPath
469
+ });
470
+ warnAboutReach(scope);
471
+ } finally {
472
+ if (minted !== void 0 && !keyIsReachable) log.info(await withdrawKey(session, mintedScope, minted.id) ? `Revoked the key that was minted but not stored (id ${minted.id}).` : `Minted key ${minted.id} could be neither stored nor revoked, and may still be live. Remove it with: neon api-keys revoke ${minted.id}${mintedScope.orgId ? ` --org-id ${mintedScope.orgId}` : ""}`);
473
+ const revoked = await revokeToken(oauthProps, tokenSet);
474
+ log.info(revoked ? "Signed the browser session back out, so nothing but the key remains." : "Could not sign the browser session back out; it will expire on its own.");
475
+ }
612
476
  };
613
477
  /**
614
- * The scope to mint at, resolving a project to the organization that owns it.
615
- *
616
- * A project-scoped key exists only on the organization endpoint, so the org has to be looked up
617
- * rather than asked for — `--project-id` alone would otherwise fail for a reason invisible from
618
- * the command line.
619
- */
478
+ * The scope to mint at, resolving a project to the organization that owns it.
479
+ *
480
+ * A project-scoped key exists only on the organization endpoint, so the org has to be looked up
481
+ * rather than asked for — `--project-id` alone would otherwise fail for a reason invisible from
482
+ * the command line.
483
+ */
620
484
  const resolveMintScope = async (props, client) => {
621
- if (props.projectId === undefined) {
622
- return props.orgId !== undefined ? { orgId: props.orgId } : {};
623
- }
624
- // `api-keys create`'s lookup, not a second one. It already answers the mistake this flag
625
- // invites — an organization id typed into the project slot — and a local copy meant the
626
- // same typo got a written explanation there and a raw 404 here.
627
- return {
628
- orgId: await orgIdForProject(client, props.projectId),
629
- projectId: props.projectId,
630
- };
485
+ if (props.projectId === void 0) return props.orgId !== void 0 ? { orgId: props.orgId } : {};
486
+ return {
487
+ orgId: await orgIdForProject(client, props.projectId),
488
+ projectId: props.projectId
489
+ };
631
490
  };
632
491
  /**
633
- * Revoke a credential a profile has just stopped using.
634
- *
635
- * Called with the credential read *before* the overwrite, and only *after* the replacement is
636
- * durable. Both halves matter. Reading it first is the only chance: a minted key's `key_id` and
637
- * an OAuth refresh token are gone the moment the file is rewritten, so nothing could revoke
638
- * them afterwards. Revoking last is what stops a cancelled sign-in or a failed write from
639
- * leaving the profile holding a credential that has already been killed.
640
- */
641
- const retirePreviousCredential = async (props, name, existing,
642
- /** The key now stored. Retiring this would kill the credential we just committed to. */
643
- replacementKey) => {
644
- if (existing === null)
645
- return;
646
- if (existing.kind === API_KEY) {
647
- // Re-storing the key a profile already holds is a no-op, not a replacement. Revoking
648
- // here would leave the profile pointing at a credential this command just committed to.
649
- if (isSameCredential(existing.apiKey, replacementKey)) {
650
- log.debug("The replacement is the credential already stored; nothing to retire.");
651
- return;
652
- }
653
- if (existing.keyId === undefined) {
654
- log.warning('Profile "%s" held an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.', name);
655
- return;
656
- }
657
- const client = getApiClient({
658
- apiKey: existing.apiKey,
659
- apiHost: props.apiHost,
660
- });
661
- log.info((await withdrawKey(client, existing.scope, existing.keyId))
662
- ? `Revoked the key it replaces (id ${existing.keyId})`
663
- : `Could not revoke the key it replaces (id ${existing.keyId}); it may still be live. Remove it with: neon api-keys revoke ${existing.keyId}`);
664
- return;
665
- }
666
- const revoked = await revokeTokenSet(existing.tokens, props);
667
- log.info(revoked
668
- ? "Signed out the session it replaced"
669
- : "Could not sign out the session it replaced; it will expire on its own");
492
+ * Revoke a credential a profile has just stopped using.
493
+ *
494
+ * Called with the credential read *before* the overwrite, and only *after* the replacement is
495
+ * durable. Both halves matter. Reading it first is the only chance: a minted key's `key_id` and
496
+ * an OAuth refresh token are gone the moment the file is rewritten, so nothing could revoke
497
+ * them afterwards. Revoking last is what stops a cancelled sign-in or a failed write from
498
+ * leaving the profile holding a credential that has already been killed.
499
+ */
500
+ const retirePreviousCredential = async (props, name, existing, replacementKey) => {
501
+ if (existing === null) return;
502
+ if (existing.kind === "api_key") {
503
+ if (isSameCredential(existing.apiKey, replacementKey)) {
504
+ log.debug("The replacement is the credential already stored; nothing to retire.");
505
+ return;
506
+ }
507
+ if (existing.keyId === void 0) {
508
+ log.warning("Profile \"%s\" held an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.", name);
509
+ return;
510
+ }
511
+ const client = getApiClient({
512
+ apiKey: existing.apiKey,
513
+ apiHost: props.apiHost
514
+ });
515
+ log.info(await withdrawKey(client, existing.scope, existing.keyId) ? `Revoked the key it replaces (id ${existing.keyId})` : `Could not revoke the key it replaces (id ${existing.keyId}); it may still be live. Remove it with: neon api-keys revoke ${existing.keyId}`);
516
+ return;
517
+ }
518
+ const revoked = await revokeTokenSet(existing.tokens, props);
519
+ log.info(revoked ? "Signed out the session it replaced" : "Could not sign out the session it replaced; it will expire on its own");
670
520
  };
671
521
  /**
672
- * Mint a key at the given scope and refuse anything that came back different.
673
- *
674
- * A 2xx is not enough. A response with no `key` leaves a live credential the user can never
675
- * see, and a `project_id` that does not match would mean storing a credential whose reach we
676
- * announced wrongly. Both are withdrawn before throwing.
677
- */
522
+ * Mint a key at the given scope and refuse anything that came back different.
523
+ *
524
+ * A 2xx is not enough. A response with no `key` leaves a live credential the user can never
525
+ * see, and a `project_id` that does not match would mean storing a credential whose reach we
526
+ * announced wrongly. Both are withdrawn before throwing.
527
+ */
678
528
  const mintKey = async (client, profile, scope) => {
679
- const keyName = mintedKeyName(profile);
680
- const { data } = scope.orgId
681
- ? await client.createOrgApiKey(scope.orgId, {
682
- key_name: keyName,
683
- ...(scope.projectId ? { project_id: scope.projectId } : {}),
684
- })
685
- : await client.createApiKey({ key_name: keyName });
686
- // Only the organization endpoint's response carries `project_id`, so narrow rather than
687
- // assert: an account key legitimately has no such field, and a *missing* one is exactly
688
- // what "unscoped" looks like.
689
- const issuedProject = "project_id" in data && typeof data.project_id === "string"
690
- ? data.project_id
691
- : undefined;
692
- const problem = typeof data.key !== "string" || data.key.trim() === ""
693
- ? "Neon returned no key."
694
- : issuedProject !== scope.projectId
695
- ? `Neon returned a key scoped to ${issuedProject ?? "nothing"} rather than ${scope.projectId ?? "the whole organization"}.`
696
- : null;
697
- if (problem === null) {
698
- return { id: data.id, key: data.key, name: data.name };
699
- }
700
- const withdrawn = await withdrawKey(client, scope, data.id);
701
- throw new Error(`${problem} ${withdrawn
702
- ? "The key has been revoked; nothing was stored."
703
- : `The key could NOT be revoked and may still be live. Remove it with \`neon api-keys revoke ${data.id}${scope.orgId ? ` --org-id ${scope.orgId}` : ""}\`.`}`);
529
+ const keyName = mintedKeyName(profile);
530
+ const { data } = scope.orgId ? await client.createOrgApiKey(scope.orgId, {
531
+ key_name: keyName,
532
+ ...scope.projectId ? { project_id: scope.projectId } : {}
533
+ }) : await client.createApiKey({ key_name: keyName });
534
+ const issuedProject = "project_id" in data && typeof data.project_id === "string" ? data.project_id : void 0;
535
+ const problem = typeof data.key !== "string" || data.key.trim() === "" ? "Neon returned no key." : issuedProject !== scope.projectId ? `Neon returned a key scoped to ${issuedProject ?? "nothing"} rather than ${scope.projectId ?? "the whole organization"}.` : null;
536
+ if (problem === null) return {
537
+ id: data.id,
538
+ key: data.key,
539
+ name: data.name
540
+ };
541
+ const withdrawn = await withdrawKey(client, scope, data.id);
542
+ throw new Error(`${problem} ${withdrawn ? "The key has been revoked; nothing was stored." : `The key could NOT be revoked and may still be live. Remove it with \`neon api-keys revoke ${data.id}${scope.orgId ? ` --org-id ${scope.orgId}` : ""}\`.`}`);
704
543
  };
705
544
  /** Best-effort withdrawal of a key we are refusing to store. Never throws. */
706
545
  const withdrawKey = async (client, scope, keyId) => {
707
- if (!Number.isSafeInteger(keyId) || keyId <= 0)
708
- return false;
709
- try {
710
- const { data } = scope.orgId
711
- ? await client.revokeOrgApiKey(scope.orgId, keyId)
712
- : await client.revokeApiKey(keyId);
713
- // Check which key the response names: a `revoked: true` for some other id is not
714
- // evidence that the one we issued is gone.
715
- return data.revoked === true && data.id === keyId;
716
- }
717
- catch (err) {
718
- log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
719
- return false;
720
- }
546
+ if (!Number.isSafeInteger(keyId) || keyId <= 0) return false;
547
+ try {
548
+ const { data } = scope.orgId ? await client.revokeOrgApiKey(scope.orgId, keyId) : await client.revokeApiKey(keyId);
549
+ return data.revoked === true && data.id === keyId;
550
+ } catch (err) {
551
+ log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
552
+ return false;
553
+ }
721
554
  };
722
555
  const rotateKey = async (props) => {
723
- const { name } = props;
724
- rejectProfileFlag(props, "rotate-key");
725
- rejectApiKeyFlag("rotate-key", `To store a key you already have, use \`neon profile create ${name} --api-key - --force\`.`);
726
- // Resolve first: an unknown profile must fail having minted nothing.
727
- const profile = resolveProfile(props.configDir, name);
728
- const at = { path: profile.credentialsPath, profile: name };
729
- const credential = await usableCredential(props, at);
730
- if (credential === null) {
731
- throw new Error(`Profile "${name}" has no usable credential to mint with. Replace it with \`neon profile create ${name} --mint --force\`.`);
732
- }
733
- // Rotating a *key* is what this command is for. An OAuth profile would otherwise be
734
- // converted into a key profile as a side effect, discarding a session it never revoked.
735
- if (credential.kind !== API_KEY) {
736
- throw new Error(`Profile "${name}" holds a browser sign-in, not an API key, so there is no key to rotate. Turn it into a key profile with \`neon profile create ${name} --mint --force\`.`);
737
- }
738
- const stored = readCredentials(at);
739
- const scope = stored !== null ? scopeOf(stored) : {};
740
- const previousKeyId = typeof stored?.key_id === "number" ? stored.key_id : undefined;
741
- const minting = getApiClient({
742
- apiKey: credential.apiKey,
743
- apiHost: props.apiHost,
744
- });
745
- // `profile` commands skip `ensureAuth`, so nothing has recorded how this call authenticated.
746
- // Without it a 401 here advises checking `--api-key` or `NEON_API_KEY`, neither of which the
747
- // user passed.
748
- setAuthContext({
749
- source: "profile-api-key",
750
- configDir: props.configDir,
751
- profile: name,
752
- credentialsPath: profile.credentialsPath,
753
- });
754
- // Neon only lets a *personal* credential mint organization keys, so an organization key
755
- // cannot mint its own replacement. Asked of the credential itself rather than of the
756
- // recorded scope: a key that was supplied rather than minted has no scope on record, and
757
- // would otherwise sail past this and get "This endpoint requires a personal API key" from
758
- // a command where the user named nothing but a profile.
759
- const { data: details } = await minting.getAuthDetails();
760
- if (details.auth_method === "api_key_org") {
761
- // Only advise a scope we know. `api_key_org` covers organization *and* project keys, so
762
- // for a key we did not mint — which records no scope — suggesting `--org-id` could hand
763
- // the profile more reach than it had.
764
- const advice = scope.projectId !== undefined
765
- ? `\`neon profile create ${name} --mint --project-id ${scope.projectId} --force\``
766
- : scope.orgId !== undefined && previousKeyId !== undefined
767
- ? `\`neon profile create ${name} --mint --org-id ${scope.orgId} --force\``
768
- : `\`neon profile create ${name} --mint --org-id ${details.account_id} --force\` — but check \`neon api-keys list --org-id ${details.account_id}\` first, because a key you supplied may have been narrowed to a single project and an organization key would reach more`;
769
- throw new Error(`Profile "${name}" holds an organization key, and only a personal credential can mint organization keys — so it cannot mint its own replacement. Sign in and mint one with ${advice}.`);
770
- }
771
- // Mint at the recorded scope. Minting an account key to replace an org or project one
772
- // would quietly widen everything the profile can reach.
773
- const created = await mintKey(minting, name, scope);
774
- log.info("Minted %s (id %d, %s)", created.name, created.id, describeScope(scope));
775
- // Store before revoking. If the write fails the old key is still valid, so the profile
776
- // keeps working — and the new key is named in the error rather than silently orphaned on
777
- // the account with no way to find it again.
778
- try {
779
- writeCredentials(profile.credentialsPath, apiKeyCredentials({
780
- apiKey: created.key,
781
- keyId: created.id,
782
- ...(typeof stored?.user_id === "string"
783
- ? { userId: stored.user_id }
784
- : {}),
785
- scope,
786
- }));
787
- }
788
- catch (err) {
789
- log.error("Minted key %d but could not write %s. Revoke it with: neon api-keys revoke %d%s", created.id, profile.credentialsPath, created.id, scope.orgId ? ` --org-id ${scope.orgId}` : "");
790
- throw err;
791
- }
792
- report(props, {
793
- name,
794
- account: profile.label ?? profile.userId ?? "unknown account",
795
- auth: "api key",
796
- scope: describeScope(scope),
797
- keyId: created.id,
798
- credentials: profile.credentialsPath,
799
- });
800
- if (previousKeyId === undefined) {
801
- log.warning("No previous key id was recorded, so the key this replaces is still live. It was supplied rather than minted here, and `GET /api_keys` exposes no prefix to match it by — find it with `neon api-keys list`.");
802
- return;
803
- }
804
- // Revoke with the new key. The old one may be exactly what stopped working, which is the
805
- // usual reason to rotate in the first place.
806
- const rotated = getApiClient({
807
- apiKey: created.key,
808
- apiHost: props.apiHost,
809
- });
810
- if (await withdrawKey(rotated, scope, previousKeyId)) {
811
- log.info("Revoked the previous key (id %d)", previousKeyId);
812
- return;
813
- }
814
- log.warning("Stored the new key, but could not revoke the previous one (id %d) — it may still be live. Revoke it with: neon api-keys revoke %d%s", previousKeyId, previousKeyId, scope.orgId ? ` --org-id ${scope.orgId}` : "");
556
+ const { name } = props;
557
+ rejectProfileFlag(props, "rotate-key");
558
+ rejectApiKeyFlag("rotate-key", `To store a key you already have, use \`neon profile create ${name} --api-key - --force\`.`);
559
+ const profile = resolveProfile(props.configDir, name);
560
+ const at = {
561
+ path: profile.credentialsPath,
562
+ profile: name
563
+ };
564
+ const credential = await usableCredential(props, at);
565
+ if (credential === null) throw new Error(`Profile "${name}" has no usable credential to mint with. Replace it with \`neon profile create ${name} --mint --force\`.`);
566
+ if (credential.kind !== "api_key") throw new Error(`Profile "${name}" holds a browser sign-in, not an API key, so there is no key to rotate. Turn it into a key profile with \`neon profile create ${name} --mint --force\`.`);
567
+ const stored = readCredentials(at);
568
+ const scope = stored !== null ? scopeOf(stored) : {};
569
+ const previousKeyId = typeof stored?.key_id === "number" ? stored.key_id : void 0;
570
+ const minting = getApiClient({
571
+ apiKey: credential.apiKey,
572
+ apiHost: props.apiHost
573
+ });
574
+ setAuthContext({
575
+ source: "profile-api-key",
576
+ configDir: props.configDir,
577
+ profile: name,
578
+ credentialsPath: profile.credentialsPath
579
+ });
580
+ const { data: details } = await minting.getAuthDetails();
581
+ if (details.auth_method === "api_key_org") {
582
+ const advice = scope.projectId !== void 0 ? `\`neon profile create ${name} --mint --project-id ${scope.projectId} --force\`` : scope.orgId !== void 0 && previousKeyId !== void 0 ? `\`neon profile create ${name} --mint --org-id ${scope.orgId} --force\`` : `\`neon profile create ${name} --mint --org-id ${details.account_id} --force\` — but check \`neon api-keys list --org-id ${details.account_id}\` first, because a key you supplied may have been narrowed to a single project and an organization key would reach more`;
583
+ throw new Error(`Profile "${name}" holds an organization key, and only a personal credential can mint organization keys — so it cannot mint its own replacement. Sign in and mint one with ${advice}.`);
584
+ }
585
+ const created = await mintKey(minting, name, scope);
586
+ log.info("Minted %s (id %d, %s)", created.name, created.id, describeScope(scope));
587
+ try {
588
+ writeCredentials(profile.credentialsPath, apiKeyCredentials({
589
+ apiKey: created.key,
590
+ keyId: created.id,
591
+ ...typeof stored?.user_id === "string" ? { userId: stored.user_id } : {},
592
+ scope
593
+ }));
594
+ } catch (err) {
595
+ log.error("Minted key %d but could not write %s. Revoke it with: neon api-keys revoke %d%s", created.id, profile.credentialsPath, created.id, scope.orgId ? ` --org-id ${scope.orgId}` : "");
596
+ throw err;
597
+ }
598
+ report(props, {
599
+ name,
600
+ account: profile.label ?? profile.userId ?? "unknown account",
601
+ auth: "api key",
602
+ scope: describeScope(scope),
603
+ keyId: created.id,
604
+ credentials: profile.credentialsPath
605
+ });
606
+ if (previousKeyId === void 0) {
607
+ log.warning("No previous key id was recorded, so the key this replaces is still live. It was supplied rather than minted here, and `GET /api_keys` exposes no prefix to match it by — find it with `neon api-keys list`.");
608
+ return;
609
+ }
610
+ const rotated = getApiClient({
611
+ apiKey: created.key,
612
+ apiHost: props.apiHost
613
+ });
614
+ if (await withdrawKey(rotated, scope, previousKeyId)) {
615
+ log.info("Revoked the previous key (id %d)", previousKeyId);
616
+ return;
617
+ }
618
+ log.warning("Stored the new key, but could not revoke the previous one (id %d) — it may still be live. Revoke it with: neon api-keys revoke %d%s", previousKeyId, previousKeyId, scope.orgId ? ` --org-id ${scope.orgId}` : "");
815
619
  };
816
620
  const remove = async (props) => {
817
- const { name } = props;
818
- rejectProfileFlag(props, "remove");
819
- rejectApiKeyFlag("remove", "It revokes the credential stored for that profile, which is the only one it can revoke.");
820
- // Resolve before touching anything: an unknown name must fail having deleted nothing.
821
- const profile = resolveProfile(props.configDir, name);
822
- if (!props.yes) {
823
- // Both halves, as every other prompt in the CLI checks. `isCi()` alone left the shape
824
- // a pipeline actually produces — stdin held by a pipe, `CI` unset — where `prompts`
825
- // draws a question nobody can answer: with stdin open it waits for input that never
826
- // comes, and at EOF it resolves to nothing, the event loop drains, and the process
827
- // exits 0 having removed nothing. Exiting 0 is the worse half: an agent reads success.
828
- if (isCi() || !process.stdin.isTTY) {
829
- throw new Error("Refusing to remove a profile without confirmation when stdin is not a terminal. Pass --yes.");
830
- }
831
- const who = profile.label ?? profile.userId ?? "unknown account";
832
- const { ok } = await prompts({
833
- type: "confirm",
834
- name: "ok",
835
- message: `Remove profile "${name}" (${who})?`,
836
- initial: false,
837
- });
838
- // Non-zero, because nothing was removed. A caller that scripts this reads the exit
839
- // code, and answering "no" is not the same outcome as removing the profile.
840
- if (!ok) {
841
- throw new Error(`Cancelled — profile "${name}" was not removed.`);
842
- }
843
- }
844
- // 1. Revoke upstream where we can, so the credential dies rather than merely becoming
845
- // unreachable by us. Best-effort: a profile is often removed precisely because its
846
- // access already broke.
847
- const at = { path: profile.credentialsPath, profile: name };
848
- const stored = readOutgoingCredential(at);
849
- if (stored?.kind === API_KEY) {
850
- const { keyId, scope } = stored;
851
- if (keyId === undefined) {
852
- // A key we did not mint has no id we can match it by, so it outlives its profile.
853
- // Deleting the file quietly would imply the credential had been destroyed.
854
- log.warning('Profile "%s" holds an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.', name);
855
- }
856
- else {
857
- const client = getApiClient({
858
- apiKey: stored.apiKey,
859
- apiHost: props.apiHost,
860
- });
861
- log.info((await withdrawKey(client, scope, keyId))
862
- ? `Revoked the API key (id ${keyId})`
863
- : `Could not revoke the API key (id ${keyId}) — it may still be live. Remove it with: neon api-keys revoke ${keyId}${scope.orgId ? ` --org-id ${scope.orgId}` : ""}`);
864
- }
865
- }
866
- else if (stored !== null) {
867
- const revoked = await revokeTokenSet(stored.tokens, props);
868
- log.info(revoked
869
- ? "Revoked the OAuth token"
870
- : "Could not revoke the OAuth token — removing locally anyway");
871
- }
872
- // 2. Delete the credentials file only if we created it. A profile pointing outside the
873
- // config directory was adopted from elsewhere; unlink it and say so, because the
874
- // secret is still on disk and silence would imply otherwise.
875
- if (existsSync(profile.credentialsPath)) {
876
- if (isInsideConfigDir(props.configDir, profile.credentialsPath)) {
877
- rmSync(profile.credentialsPath);
878
- log.info("Deleted %s", profile.credentialsPath);
879
- }
880
- else {
881
- log.info("Left %s on disk — not created by neon", profile.credentialsPath);
882
- }
883
- }
884
- // 3. Drop the entry, and the file once nothing but DEFAULT is left — the mirror image
885
- // of creating it lazily, so a single-account install ends up with no profiles.json.
886
- const path = profilesFilePath(props.configDir);
887
- const file = readProfiles(props.configDir, log.warning);
888
- if (file?.profiles[name]) {
889
- delete file.profiles[name];
890
- if (onlyDefaultRemains(file)) {
891
- rmSync(path);
892
- log.info('Removed "%s" — no profiles left, deleted %s', name, path);
893
- }
894
- else {
895
- writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
896
- log.info('Removed "%s" from %s', name, path);
897
- }
898
- }
899
- else if (name === DEFAULT_PROFILE) {
900
- log.info("Signed out of DEFAULT");
901
- }
621
+ const { name } = props;
622
+ rejectProfileFlag(props, "remove");
623
+ rejectApiKeyFlag("remove", "It revokes the credential stored for that profile, which is the only one it can revoke.");
624
+ const profile = resolveProfile(props.configDir, name);
625
+ if (!props.yes) {
626
+ if (isCi() || !process.stdin.isTTY) throw new Error("Refusing to remove a profile without confirmation when stdin is not a terminal. Pass --yes.");
627
+ const who = profile.label ?? profile.userId ?? "unknown account";
628
+ const { ok } = await prompts({
629
+ type: "confirm",
630
+ name: "ok",
631
+ message: `Remove profile "${name}" (${who})?`,
632
+ initial: false
633
+ });
634
+ if (!ok) throw new Error(`Cancelled — profile "${name}" was not removed.`);
635
+ }
636
+ const at = {
637
+ path: profile.credentialsPath,
638
+ profile: name
639
+ };
640
+ const stored = readOutgoingCredential(at);
641
+ if (stored?.kind === "api_key") {
642
+ const { keyId, scope } = stored;
643
+ if (keyId === void 0) log.warning("Profile \"%s\" holds an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.", name);
644
+ else {
645
+ const client = getApiClient({
646
+ apiKey: stored.apiKey,
647
+ apiHost: props.apiHost
648
+ });
649
+ log.info(await withdrawKey(client, scope, keyId) ? `Revoked the API key (id ${keyId})` : `Could not revoke the API key (id ${keyId}) — it may still be live. Remove it with: neon api-keys revoke ${keyId}${scope.orgId ? ` --org-id ${scope.orgId}` : ""}`);
650
+ }
651
+ } else if (stored !== null) {
652
+ const revoked = await revokeTokenSet(stored.tokens, props);
653
+ log.info(revoked ? "Revoked the OAuth token" : "Could not revoke the OAuth token — removing locally anyway");
654
+ }
655
+ if (existsSync(profile.credentialsPath)) if (isInsideConfigDir(props.configDir, profile.credentialsPath)) {
656
+ rmSync(profile.credentialsPath);
657
+ log.info("Deleted %s", profile.credentialsPath);
658
+ } else log.info("Left %s on disk — not created by neon", profile.credentialsPath);
659
+ const path = profilesFilePath(props.configDir);
660
+ const file = readProfiles(props.configDir, log.warning);
661
+ if (file?.profiles[name]) {
662
+ delete file.profiles[name];
663
+ if (onlyDefaultRemains(file)) {
664
+ rmSync(path);
665
+ log.info("Removed \"%s\" — no profiles left, deleted %s", name, path);
666
+ } else {
667
+ writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
668
+ log.info("Removed \"%s\" from %s", name, path);
669
+ }
670
+ } else if (name === "DEFAULT") log.info("Signed out of DEFAULT");
902
671
  };
903
672
  /** Revoke the OAuth refresh token in a credential we already have in hand. */
904
673
  const revokeTokenSet = async (credentials, props) => {
905
- const tokenSet = credentials;
906
- return await revokeToken({
907
- oauthHost: props.oauthHost,
908
- clientId: props.clientId,
909
- ...(props.allowUnsafeTls
910
- ? { allowUnsafeTls: props.allowUnsafeTls }
911
- : {}),
912
- }, tokenSet);
674
+ const tokenSet = credentials;
675
+ return await revokeToken({
676
+ oauthHost: props.oauthHost,
677
+ clientId: props.clientId,
678
+ ...props.allowUnsafeTls ? { allowUnsafeTls: props.allowUnsafeTls } : {}
679
+ }, tokenSet);
913
680
  };
681
+ //#endregion
682
+ export { aliases, builder, command, describe, handler, profile_exports };