neon 2.43.0 → 2.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -644,27 +644,34 @@ The CLI holds one Neon account by default. A profile adds another, and is nothin
644
644
  ```
645
645
  ~/.config/neon/
646
646
  ├── credentials.json # this IS the DEFAULT profile
647
- ├── credentials.work.json # created by `neon auth --profile work`
647
+ ├── credentials.work.json # created by `neon profile create work`
648
648
  └── profiles.json # created only once a second profile exists
649
649
  ```
650
650
 
651
651
  ```bash
652
- neon auth --profile work # create it, or sign in again
653
- neon profiles list
654
- neon profiles remove work
652
+ neon profile create work # a browser sign-in, or an API key — see below
653
+ neon profile list
654
+ neon profile remove work
655
655
  ```
656
656
 
657
657
  ```console
658
- $ neon profiles list
658
+ $ neon profile list
659
659
  Profiles
660
- ┌────────┬─────────┬──────────────────────┬──────────┬──────────────────────────────────────┐
661
- │ Active │ Name │ Account │ SignedIn │ Credentials │
662
- ├────────┼─────────┼──────────────────────┼──────────┼──────────────────────────────────────┤
663
- │ * │ DEFAULT │ me@example.com │ yes │ ~/.config/neon/credentials.json │
664
- ├────────┼─────────┼──────────────────────┼──────────┼──────────────────────────────────────┤
665
- │ │ work │ me@work.example.com │ yes │ ~/.config/neon/credentials.work.json │
666
- └────────┴─────────┴──────────────────────┴──────────┴──────────────────────────────────────┘
667
- ```
660
+ ┌────────┬─────────┬───────────────────────┬─────────┬────────────────┬──────┬────────────────────────┐
661
+ │ Active │ Name │ Account │ Auth │ Scope │ File │ Credentials │
662
+ ├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
663
+ │ * │ DEFAULT │ me@example.com │ oauth │ - │ ok │ credentials.json │
664
+ ├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
665
+ │ │ work │ me@example.com │ api key │ account │ ok │ credentials.work.json │
666
+ ├────────┼─────────┼───────────────────────┼─────────┼────────────────┼──────┼────────────────────────┤
667
+ │ │ ci │ org-old-flower-827148 │ api key │ project proj-1 │ ok │ credentials.ci.json │
668
+ └────────┴─────────┴───────────────────────┴─────────┴────────────────┴──────┴────────────────────────┘
669
+ ```
670
+
671
+ `Scope` is what a key can reach; an OAuth session has none of its own, so it shows `-`. `File`
672
+ says whether the credentials file can be read and understood — `ok`, `invalid` or `missing` —
673
+ which is not the same as the credential still working; only using it shows that. The table shows
674
+ the file name, `--output json` the full path.
668
675
 
669
676
  Select one per invocation with `--profile`, or per shell with `NEON_PROFILE`. There is no `profile use` command and nothing is stored about which profile is "current", so what you type is always what runs.
670
677
 
@@ -680,7 +687,96 @@ Entries in `profiles.json` are paths, and a path may point anywhere — which is
680
687
  }
681
688
  ```
682
689
 
683
- `neon profiles remove` revokes the refresh token at the authorization server, not just locally. It deletes the credentials file only when the CLI created it: an adopted path like the one above is unlinked and left on disk, and the command says so. Removing the last named profile deletes `profiles.json`, returning you to the single-account layout. `neon profiles remove DEFAULT` signs you out.
690
+ `neon profile remove` revokes what the profile holds — an OAuth refresh token at the
691
+ authorization server, or an API key this CLI minted — rather than only forgetting it locally. A
692
+ key you supplied is the exception and stays live, because nothing records its id; the command
693
+ says so. It asks for confirmation first, which `--yes` skips; without a terminal on stdin, in
694
+ CI or behind a pipe, it refuses rather than prompting into the void. It deletes the credentials
695
+ file only when the CLI created it: an adopted path like the one above is unlinked and left on
696
+ disk, and the command says so. Removing the last named profile deletes `profiles.json`,
697
+ returning you to the single-account layout. `neon profile remove DEFAULT` signs you out.
698
+
699
+ ### A profile holds either a sign-in or an API key
700
+
701
+ `neon profile create` makes a profile, and how you call it decides which kind of credential it holds. A key-backed profile is what you want for an agent, a shared machine, or anything that must never be interrupted by a browser:
702
+
703
+ ```bash
704
+ neon profile create work # sign in with the browser, like `neon auth`
705
+ neon profile create work --api-key "$KEY" # store a key you already have
706
+ echo "$KEY" | neon profile create work --api-key - # or pipe it, keeping it out of argv
707
+ neon profile create ci --mint # sign in once, keep only a minted key
708
+ neon profile create ci --mint --org-id org-abc-123 # minted for an organization
709
+ neon profile create ci --mint --project-id proj-1 # minted for one project only
710
+ neon profile create work --force # replace it, revoking what it holds now
711
+ neon profile rotate-key work # mint a replacement, revoke the old one
712
+ ```
713
+
714
+ `--force` is not only a local edit: replacing a profile revokes the credential it held, so a key
715
+ this CLI minted stops working everywhere it was pasted, and an OAuth session is signed out.
716
+ Without `--force`, `create` refuses and names what would be revoked. To keep a working profile
717
+ and swap only its key, use `rotate-key`.
718
+
719
+ `create` and `rotate-key` print the profile they wrote, so an agent needn't follow up with
720
+ `list`. Under `--output json` that is a record, and it never carries the secret:
721
+
722
+ ```console
723
+ $ neon profile create ci --mint --org-id org-abc-123 --output json
724
+ {"name":"ci","account":"org-abc-123","auth":"api key","scope":"org org-abc-123","keyId":3239771,"credentials":"/home/me/.config/neon/credentials.ci.json"}
725
+ ```
726
+
727
+ One flag takes the key, because the shell already covers the variations: `--api-key "$(cat
728
+ ~/keys/work)"` reads a file and `--api-key "$KEY"` takes a variable. Those put the key in the
729
+ process arguments, where `ps` and shell history can see it, so `--api-key -` reads it from stdin
730
+ instead — the usual convention for a piped value. `--mint` avoids the question entirely, because
731
+ the key never leaves the CLI.
732
+
733
+ **A profile is one kind or the other, never both.** `type` in the credentials file states which:
734
+
735
+ ```json
736
+ // oauth: what a plain `create` (or `neon auth --profile`) writes. An absent `type` means this.
737
+ { "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
738
+
739
+ // api_key: what `--api-key` writes
740
+ { "type": "api_key", "api_key": "napi_…", "user_id": "…" }
741
+
742
+ // api_key from `--mint --org-id`, which records the scope it was issued at
743
+ { "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
744
+ ```
745
+
746
+ Nothing is carried over when a profile is replaced, so one profile can never hold two credentials — or two different accounts. The secret stays in that file and never goes into `profiles.json`, so listing profiles cannot leak one. Both files are written owner-only through a temporary file and a rename, which also repairs the permissions of a file created too permissively.
747
+
748
+ `--mint` is the one to reach for. It signs you in through the browser once, mints a key with that session, stores only the key, and signs the session back out — so afterwards nothing about the profile can open a browser, and no half-forgotten login is left behind. `--org-id` and `--project-id` narrow what the minted key can reach, exactly as they do on [`neon api-keys create`](#api-keys); a project-scoped key cannot create projects, mint keys, or read any other project.
749
+
750
+ Every key is verified against the API before it is stored, and the account it belongs to is recorded so `profile list` can show it. Only a real API key is accepted: an OAuth access token authenticates today and then expires with nothing to refresh it.
751
+
752
+ `rotate-key` mints at the scope the profile already has — replacing an org key with an account key would quietly widen everything it reaches — and stores the new key before revoking the old one, so a failed write leaves the old key working.
753
+
754
+ One thing it cannot do: **an organization key cannot mint its own replacement.** Neon only accepts a personal credential when creating organization keys, so rotating an org- or project-scoped profile means signing in again — `neon profile create ci --mint --org-id org-abc-123 --force`. `rotate-key` checks this before minting and says so, rather than letting the API answer with a rule you had no reason to expect.
755
+
756
+ Two things the CLI cannot do for a key you supplied rather than minted. It cannot revoke it, because `GET /api_keys` exposes no prefix and a stored secret cannot be matched to a listing entry, so both `rotate-key` and `profile remove` say the old key is still live and point you at `neon api-keys list`. For a key you supplied it records the organization the API reports, but cannot know whether that key was narrowed to a single project — so `rotate-key` will not suggest an organization-wide replacement without telling you to check `neon api-keys list` first.
757
+
758
+ If a stored key stops working there is nothing to refresh, so recovery is one browser sign-in: `neon profile create work --mint --force`.
759
+
760
+ ### Which credential an invocation uses
761
+
762
+ An explicit flag always beats an environment variable:
763
+
764
+ | Given | What runs |
765
+ | --- | --- |
766
+ | `--api-key` and `--profile` | neither — contradictory, so the command fails |
767
+ | `--api-key` and `NEON_PROFILE` | the flag's key |
768
+ | `--profile` and `NEON_API_KEY` | the profile |
769
+ | `NEON_API_KEY` and `NEON_PROFILE` | the key, and the ignored profile is named in a warning |
770
+ | `--profile` or `NEON_PROFILE` alone | that profile |
771
+ | nothing | `DEFAULT` |
772
+
773
+ Passing both flags fails rather than picking a winner: `--api-key` supplies a credential and `--profile` selects a stored one, so there is no reading of the command that makes both true.
774
+
775
+ When both are only environment variables the key wins, which keeps a CI pipeline that injects `NEON_API_KEY` working even if a `NEON_PROFILE` leaks into the environment — but the disregarded profile is named on stderr rather than passed over silently.
776
+
777
+ `neon auth` and the `profile` subcommands are outside all of this, because they read the same flags to mean something else: `neon auth --profile work` names where to write a credential, and `neon profile create work --api-key …` names one to store.
778
+
779
+ `neon init` does not support `--profile` yet. It hands its whole auth flow to `neon-init`, which reads the default credentials directly, so passing the flag fails instead of quietly running as the default account.
684
780
 
685
781
  ## API keys (`api-keys`)
686
782
 
@@ -759,7 +855,7 @@ API keys in org-7
759
855
  | Command | Subcommands | Description |
760
856
  | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
761
857
  | [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
762
- | profiles | `list`, `remove` | Manage named sets of credentials |
858
+ | profile | `list`, `create`, `rotate-key`, `remove` | Manage named sets of credentials |
763
859
  | api-keys | `list`, `create`, `revoke` | Manage API keys |
764
860
  | [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
765
861
  | [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
@@ -0,0 +1,86 @@
1
+ /**
2
+ * # Which credential an invocation authenticates with
3
+ *
4
+ * Four inputs can each answer "who am I": the `--api-key` flag, `NEON_API_KEY`, the
5
+ * `--profile` flag, and `NEON_PROFILE`. This module decides between them, and it is pure so
6
+ * the decision can be tested without a filesystem, a network, or a config directory.
7
+ *
8
+ * ## The rule
9
+ *
10
+ * **An explicit flag beats an ambient environment variable.** That single rule fixes the bug
11
+ * this module exists for: before it, any API key — including one merely exported into the
12
+ * shell — silently voided `--profile`, so `neon --profile work …` would quietly run as
13
+ * whoever `NEON_API_KEY` belonged to and say nothing about it.
14
+ *
15
+ * | Given | What runs |
16
+ * | --- | --- |
17
+ * | `--api-key` and `--profile` | neither: contradictory explicit flags, so this throws |
18
+ * | `--api-key` and `NEON_PROFILE` | the flag's key |
19
+ * | `--profile` and `NEON_API_KEY` | the profile |
20
+ * | `NEON_API_KEY` and `NEON_PROFILE` | the key, and the ignored profile is named in a warning |
21
+ * | `--profile` or `NEON_PROFILE` alone | that profile |
22
+ * | nothing | `DEFAULT` |
23
+ *
24
+ * Two explicit flags throw rather than picking a winner. They express different intents —
25
+ * `--api-key` supplies a credential, `--profile` selects a stored one — so there is no
26
+ * reading of the command that makes both true, and guessing is how the original bug behaved.
27
+ *
28
+ * When both are merely ambient, the key wins. That keeps CI exactly as it was: a pipeline
29
+ * that injects `NEON_API_KEY` must not change behaviour because a `NEON_PROFILE` leaked into
30
+ * the environment. It warns instead of staying silent, because a disregarded account
31
+ * selection is precisely what nobody noticed last time.
32
+ *
33
+ * `auth` and the `profile` subcommands do not use any of this. They read the same flags with
34
+ * different meanings — `neon auth --profile work` names where to *write* a credential, and
35
+ * `neon profile create work --api-key …` names one to *store* — so their callers skip
36
+ * selection entirely rather than passing exemptions down here.
37
+ */
38
+ import { DEFAULT_PROFILE } from "./profiles.js";
39
+ const NO_INPUTS = {
40
+ apiKeyFlag: "",
41
+ apiKeyEnv: "",
42
+ profileEnv: "",
43
+ };
44
+ let inputs = NO_INPUTS;
45
+ export const recordCredentialInputs = (recorded) => {
46
+ inputs = recorded;
47
+ };
48
+ export const credentialInputs = () => inputs;
49
+ export const selectCredential = ({ apiKeyFlag, profileFlag, apiKeyEnv, profileEnv, }) => {
50
+ const flagKey = nonEmpty(apiKeyFlag);
51
+ const flagProfile = nonEmpty(profileFlag);
52
+ if (flagKey !== undefined && flagProfile !== undefined) {
53
+ throw new Error("Pass either --api-key or --profile, not both. --api-key supplies a credential directly; --profile selects a stored one.");
54
+ }
55
+ if (flagKey !== undefined) {
56
+ return { source: "explicit-api-key", apiKey: flagKey };
57
+ }
58
+ if (flagProfile !== undefined) {
59
+ return { source: "profile", profile: flagProfile, explicit: true };
60
+ }
61
+ const envKey = nonEmpty(apiKeyEnv);
62
+ const envProfile = nonEmpty(profileEnv);
63
+ if (envKey !== undefined) {
64
+ return {
65
+ source: "ambient-api-key",
66
+ apiKey: envKey,
67
+ ...(envProfile !== undefined ? { ignoredProfile: envProfile } : {}),
68
+ };
69
+ }
70
+ return {
71
+ source: "profile",
72
+ profile: envProfile ?? DEFAULT_PROFILE,
73
+ explicit: envProfile !== undefined,
74
+ };
75
+ };
76
+ /** The warning for an ambient key that displaced an ambient profile, or `null`. */
77
+ export const displacedProfileWarning = (selection) => selection.source === "ambient-api-key" &&
78
+ selection.ignoredProfile !== undefined
79
+ ? `NEON_API_KEY is set, so profile "${selection.ignoredProfile}" from NEON_PROFILE was ignored. Pass --profile ${selection.ignoredProfile} to use it instead.`
80
+ : null;
81
+ function nonEmpty(value) {
82
+ if (typeof value !== "string")
83
+ return undefined;
84
+ const trimmed = value.trim();
85
+ return trimmed === "" ? undefined : trimmed;
86
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * # Stored credentials — one file per account, two kinds
3
+ *
4
+ * A profile points at exactly one credentials file (see `./profiles.ts`), and that file says
5
+ * what kind of credential it holds. Adding API-key support this way rather than adding a
6
+ * second pointer to `profiles.json` keeps a profile what it already was — one name, one path
7
+ * — and means `profiles.json` needs no schema change at all.
8
+ *
9
+ * ```json
10
+ * // oauth: every file written before this existed. An absent `type` means this.
11
+ * { "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
12
+ *
13
+ * // api_key, stored by `neon profile create --api-key`
14
+ * { "type": "api_key", "api_key": "napi_…", "user_id": "…" }
15
+ *
16
+ * // api_key minted by `--mint --org-id`, which records the scope it was issued at
17
+ * { "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
18
+ * ```
19
+ *
20
+ * ## One profile, one kind
21
+ *
22
+ * A credentials file holds an API key or an OAuth session, never both, and `type` states
23
+ * which. An earlier draft let the two coexist — the idea being that a key could keep the
24
+ * session it was minted from and so rotate without a browser. It did not survive review, for
25
+ * two reasons that are worth recording so nobody rebuilds it:
26
+ *
27
+ * 1. **It never worked.** The resolver returned the key without testing it, so a revoked key
28
+ * failed to mint and never fell back to the session sitting beside it.
29
+ * 2. **It could mix accounts.** Nothing compared the identity of the credential being written
30
+ * with the one already there, so a profile could hold one account's session and another's
31
+ * key, told apart only by a single string. Flip or lose `type` and the profile silently
32
+ * becomes a different person.
33
+ *
34
+ * Recovery from a dead key is therefore one browser login — `neon profile create <name>
35
+ * --mint --force` — which is what the retained session was supposed to save and never did.
36
+ *
37
+ * ## Older releases
38
+ *
39
+ * A CLI predating this reads the pointer, finds no `type` it understands, ignores it, and
40
+ * looks for `access_token`. An `api_key` profile has none, so an older release falls through
41
+ * to its browser login rather than crashing. That it does not crash is why `credentials`
42
+ * stays a required pointer: an entry without one makes 2.41 and 2.42 throw
43
+ * `ERR_INVALID_ARG_TYPE` from `resolveEntryPath`.
44
+ */
45
+ import { readFileSync } from "node:fs";
46
+ import { writeSecretFile } from "./secure_file.js";
47
+ export const OAUTH = "oauth";
48
+ export const API_KEY = "api_key";
49
+ /**
50
+ * Which credential in this file authenticates, by declaration alone.
51
+ *
52
+ * An unrecognised `type` throws rather than falling back to `oauth`. A file we cannot
53
+ * interpret is a misconfiguration the user has to see: treating it as OAuth would send them
54
+ * to a browser login that silently replaces a credential they meant to keep, and treating it
55
+ * as an API key would authenticate with whatever `api_key` happened to be there.
56
+ *
57
+ * This deliberately does not check that an `api_key` file has a key — `neon profile list`
58
+ * needs the kind of a file it is not about to authenticate with, and must be able to report a
59
+ * broken one rather than throwing halfway through a table.
60
+ */
61
+ export const credentialKind = (credentials, at) => {
62
+ const declared = credentials.type;
63
+ if (declared === undefined || declared === OAUTH)
64
+ return OAUTH;
65
+ if (declared === API_KEY)
66
+ return API_KEY;
67
+ // The value is not quoted back. Everything in this file is secret material, and a
68
+ // corrupted or hand-edited file can put a key anywhere in it — including here. Naming the
69
+ // file is enough to act on, and it cannot leak what the file holds.
70
+ throw new Error(`${at.path} declares a "type" this version does not understand. Expected "${OAUTH}" or "${API_KEY}". ${repair(at)}`);
71
+ };
72
+ /**
73
+ * The way out of a credentials file that cannot be read.
74
+ *
75
+ * One sentence, shared by every such error, because they all have the same two answers: write
76
+ * a new credential over it, or delete it and start again.
77
+ */
78
+ const repair = (at) => `Replace it deliberately with \`neon profile create ${at.profile} --force\`, or delete the file.`;
79
+ /**
80
+ * Resolve what to authenticate with, validating that the declared kind is actually usable.
81
+ *
82
+ * An `api_key` file with no key is a hard error rather than a fall-through to OAuth: the user
83
+ * asked for a key, and quietly opening a browser instead would replace the credential they
84
+ * were trying to fix.
85
+ */
86
+ export const interpretCredentials = (credentials, at) => {
87
+ if (credentialKind(credentials, at) === OAUTH)
88
+ return { kind: OAUTH };
89
+ const apiKey = nonEmpty(credentials.api_key);
90
+ if (apiKey === undefined) {
91
+ throw new Error(`${at.path} declares "type": "${API_KEY}" but has no "api_key" value. ${repair(at)}`);
92
+ }
93
+ return { kind: API_KEY, apiKey };
94
+ };
95
+ /**
96
+ * Read and classify a credentials file, without deciding what to do about it.
97
+ *
98
+ * A permission or I/O error still throws: there may be a perfectly good credential here that
99
+ * we cannot see, and treating that as absent would send the user to a browser login that
100
+ * overwrites it.
101
+ */
102
+ export const inspectCredentials = (path) => {
103
+ let contents;
104
+ try {
105
+ contents = readFileSync(path, "utf8");
106
+ }
107
+ catch (err) {
108
+ if (err.code === "ENOENT")
109
+ return { kind: "absent" };
110
+ throw err;
111
+ }
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(contents);
115
+ }
116
+ catch {
117
+ // The parser's message is deliberately discarded. V8 quotes a window of the input
118
+ // around the syntax error — on Node 24 a truncated credentials file produced
119
+ // `Unexpected token 'a', ..."api_key":napi_SUPERS"... is not valid JSON` — and this
120
+ // reason is printed by `profile list` and by every failed authentication. A malformed
121
+ // secret file is exactly when a diagnostic must say less, not more.
122
+ return {
123
+ kind: "unusable",
124
+ reason: `${path} is not valid JSON, so the credential in it cannot be read`,
125
+ };
126
+ }
127
+ if (parsed === null ||
128
+ typeof parsed !== "object" ||
129
+ Array.isArray(parsed)) {
130
+ return {
131
+ kind: "unusable",
132
+ reason: `${path} does not contain a credentials object`,
133
+ };
134
+ }
135
+ return { kind: "ok", credentials: parsed };
136
+ };
137
+ /**
138
+ * The credential at `path`, or `null` when the file is not there.
139
+ *
140
+ * A damaged file is an error, not an absence. Treating it as absent — which is what this used to
141
+ * do — meant any read-only command could repair it by starting a browser sign-in and overwriting
142
+ * it, **possibly as a different account**, with the user never having asked for a repair and no
143
+ * way back to whatever was in the file. Failing here costs one deliberate command; the message
144
+ * names it.
145
+ *
146
+ * `profile list` and telemetry use {@link inspectCredentials} instead, because describing a
147
+ * broken credential is not the same as using one.
148
+ */
149
+ export const readCredentials = (at) => {
150
+ const read = inspectCredentials(at.path);
151
+ if (read.kind === "unusable") {
152
+ throw new Error(`${read.reason}. ${repair(at)}`);
153
+ }
154
+ return read.kind === "ok" ? read.credentials : null;
155
+ };
156
+ export const writeCredentials = (path, credentials) => {
157
+ writeSecretFile(path, JSON.stringify(credentials));
158
+ };
159
+ /**
160
+ * Build an `api_key` credentials object. Nothing from a previous credential is carried over.
161
+ *
162
+ * The scope is stored because it is not recoverable from the secret: `rotate-key` has to mint
163
+ * the replacement on the same endpoint, and an org or project key minted as an account key
164
+ * would silently widen what the profile reaches.
165
+ */
166
+ export const apiKeyCredentials = ({ apiKey, keyId, userId, scope, }) => ({
167
+ type: API_KEY,
168
+ api_key: apiKey,
169
+ ...(keyId !== undefined ? { key_id: keyId } : {}),
170
+ ...(userId !== undefined ? { user_id: userId } : {}),
171
+ ...(scope?.orgId !== undefined ? { org_id: scope.orgId } : {}),
172
+ ...(scope?.projectId !== undefined ? { project_id: scope.projectId } : {}),
173
+ });
174
+ /** The scope recorded on a stored credential. */
175
+ export const scopeOf = (credentials) => ({
176
+ ...(typeof credentials.org_id === "string"
177
+ ? { orgId: credentials.org_id }
178
+ : {}),
179
+ ...(typeof credentials.project_id === "string"
180
+ ? { projectId: credentials.project_id }
181
+ : {}),
182
+ });
183
+ /** How to describe a scope in output. */
184
+ export const describeScope = (scope) => {
185
+ if (scope.projectId !== undefined)
186
+ return `project ${scope.projectId}`;
187
+ if (scope.orgId !== undefined)
188
+ return `org ${scope.orgId}`;
189
+ return "account";
190
+ };
191
+ function nonEmpty(value) {
192
+ if (typeof value !== "string")
193
+ return undefined;
194
+ const trimmed = value.trim();
195
+ return trimmed === "" ? undefined : trimmed;
196
+ }
197
+ /**
198
+ * Whether a stored credential is the same secret as the one about to replace it.
199
+ *
200
+ * Re-storing the key a profile already holds is a no-op, not a replacement — and retiring it
201
+ * would revoke the credential the command has just committed to. Trimmed on both sides, because
202
+ * a key read from a file or a pipe arrives with a trailing newline.
203
+ */
204
+ export const isSameCredential = (existingKey, replacementKey) => {
205
+ if (existingKey === undefined || replacementKey === undefined)
206
+ return false;
207
+ const trimmed = existingKey.trim();
208
+ return trimmed !== "" && trimmed === replacementKey.trim();
209
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * # Where the Neon CLIs keep their files on disk
3
+ *
4
+ **Deliberately impure.** It reads environment variables and touches the filesystem, which
5
+ * `@neon/config` — the package this used to be a subpath of — must never do from its root
6
+ * export. It lives here instead of there precisely so that a policy-facing package does not
7
+ * carry implementor-only code, and so `neon-init`, which has no workspace dependencies, can use
8
+ * the same resolution as everything else.
9
+ *
10
+ * It exists because three separate readers each grew their own answer to "where is the
11
+ * config directory", and all three disagreed: `packages/cli` honoured `XDG_CONFIG_HOME` but
12
+ * not `NEONCTL_CONFIG_DIR`, `packages/env` honoured the env var but not XDG, and
13
+ * `packages/init` hardcoded `~/.config/neonctl`. With `XDG_CONFIG_HOME` set, the CLI wrote
14
+ * credentials somewhere the other two never looked.
15
+ *
16
+ * ## The directory
17
+ *
18
+ * `neon` is the current name; `neonctl` is the legacy one, kept readable forever. Resolution,
19
+ * each entry winning over the next:
20
+ *
21
+ * 1. An explicit directory (a `--config-dir` flag) — **exact**, no legacy fallback.
22
+ * 2. `NEON_CONFIG_DIR` — exact.
23
+ * 3. `NEONCTL_CONFIG_DIR` (legacy name) — exact.
24
+ * 4. `$XDG_CONFIG_HOME/neon`, else `<home>/.config/neon`.
25
+ *
26
+ * An explicitly chosen directory is never paired with a fallback: `--config-dir /tmp/ci` that
27
+ * quietly read `~/.config/neonctl` would defeat the point of passing it.
28
+ *
29
+ * ## The files
30
+ *
31
+ * {@link resolveConfigFile} answers "which path should I use for this file", and it is the
32
+ * same answer for reading and writing:
33
+ *
34
+ * - Present in `neon/` → use it.
35
+ * - Present only in `neonctl/` → **use it there, in place.** An existing credentials file is
36
+ * never copied or moved, so nothing is left behind to go stale and no other tool starts
37
+ * reading an abandoned token.
38
+ * - Present in neither → the new location. New files only ever appear under `neon/`.
39
+ */
40
+ import { existsSync } from "node:fs";
41
+ import { join, resolve } from "node:path";
42
+ /** Current directory name. New files are created here. */
43
+ export const CONFIG_DIR_NAME = "neon";
44
+ /** Legacy directory name, read forever so existing installs keep working untouched. */
45
+ export const LEGACY_CONFIG_DIR_NAME = "neonctl";
46
+ /** Where files are created. See the module docs for the precedence. */
47
+ export function configDir(options = {}) {
48
+ const explicit = explicitDir(options);
49
+ if (explicit)
50
+ return explicit;
51
+ return join(configHome(options.env ?? process.env), CONFIG_DIR_NAME);
52
+ }
53
+ /**
54
+ * The legacy directory, or `undefined` when the location was chosen explicitly (in which
55
+ * case there is no legacy counterpart to fall back to).
56
+ */
57
+ export function legacyConfigDir(options = {}) {
58
+ if (explicitDir(options))
59
+ return undefined;
60
+ return join(configHome(options.env ?? process.env), LEGACY_CONFIG_DIR_NAME);
61
+ }
62
+ /**
63
+ * Resolve one file inside the config directory. Prefers the current location, falls back to
64
+ * an existing legacy file **in place**, and otherwise points at the current location so new
65
+ * files are created there.
66
+ */
67
+ export function resolveConfigFile(fileName, options = {}) {
68
+ const dir = configDir(options);
69
+ const current = resolve(dir, fileName);
70
+ if (existsSync(current))
71
+ return { path: current, dir, isLegacy: false, exists: true };
72
+ const legacyDir = legacyConfigDir(options);
73
+ if (legacyDir) {
74
+ const legacy = resolve(legacyDir, fileName);
75
+ if (existsSync(legacy))
76
+ return {
77
+ path: legacy,
78
+ dir: legacyDir,
79
+ isLegacy: true,
80
+ exists: true,
81
+ };
82
+ }
83
+ return { path: current, dir, isLegacy: false, exists: false };
84
+ }
85
+ /** `$XDG_CONFIG_HOME`, else `<home>/.config`. Falls back to a relative `.config` with no home. */
86
+ function configHome(env) {
87
+ const xdg = nonEmpty(env.XDG_CONFIG_HOME);
88
+ if (xdg)
89
+ return xdg;
90
+ const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
91
+ return home ? join(home, ".config") : ".config";
92
+ }
93
+ function explicitDir(options) {
94
+ const env = options.env ?? process.env;
95
+ return (nonEmpty(options.dir) ??
96
+ nonEmpty(env.NEON_CONFIG_DIR) ??
97
+ nonEmpty(env.NEONCTL_CONFIG_DIR));
98
+ }
99
+ function nonEmpty(value) {
100
+ if (typeof value !== "string")
101
+ return undefined;
102
+ const trimmed = value.trim();
103
+ return trimmed === "" ? undefined : trimmed;
104
+ }
105
+ export const CREDENTIALS_FILE = "credentials.json";
106
+ /**
107
+ * Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
108
+ *
109
+ * The directory was called `neonctl` until the CLI was renamed. An existing one is still read —
110
+ * see {@link credentialsPath} — but it is never written to, moved, or deleted.
111
+ */
112
+ export const defaultDir = configDir();
113
+ /**
114
+ * Where this invocation's `credentials.json` lives.
115
+ *
116
+ * When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
117
+ * directory is used **in place**: an install that predates the rename keeps working, and its
118
+ * credentials are never duplicated into a second location where one copy could go stale while
119
+ * another tool still reads it.
120
+ *
121
+ * A `--config-dir` the user actually passed is used exactly as given. Falling back out of an
122
+ * explicitly chosen directory would defeat the reason for choosing it — a CI run pointed at a
123
+ * scratch directory must never pick up a developer's real credentials.
124
+ */
125
+ export const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
126
+ /**
127
+ * Whether a credentials file is one the CLI created, rather than a path a profile adopted.
128
+ *
129
+ * Anything that deletes a credential has to ask this first. A profile entry may point anywhere —
130
+ * that is what makes adopting an existing directory a one-line edit — and a file we did not
131
+ * create is not ours to remove.
132
+ */
133
+ export const isInsideConfigDir = (configDirectory, file) => `${resolve(file)}/`.startsWith(`${resolve(configDirectory)}/`);
134
+ /**
135
+ * Whether a credentials file is one the CLI owns, counting the legacy `neonctl` directory.
136
+ *
137
+ * {@link credentialsPath} deliberately reads an existing legacy file in place rather than
138
+ * migrating it, so for a default config directory that file is ours even though it sits outside
139
+ * `neon/`. Judging ownership on the current directory alone would call an install that predates
140
+ * the rename "adopted".
141
+ */
142
+ export const isOwnedCredentialPath = (configDirectory, file) => {
143
+ if (isInsideConfigDir(configDirectory, file))
144
+ return true;
145
+ if (configDirectory !== defaultDir)
146
+ return false;
147
+ const legacy = legacyConfigDir();
148
+ return legacy !== undefined && isInsideConfigDir(legacy, file);
149
+ };