neon 2.41.0 → 2.43.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 +78 -5
- package/dist/api.js +98 -10
- package/dist/commands/api_keys.js +397 -0
- package/dist/commands/config.js +81 -19
- package/dist/commands/index.js +2 -0
- package/dist/commands/profile.js +4 -4
- package/dist/config_template.js +126 -4
- package/dist/context.js +22 -1
- package/dist/utils/enrichers.js +1 -3
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -650,12 +650,12 @@ The CLI holds one Neon account by default. A profile adds another, and is nothin
|
|
|
650
650
|
|
|
651
651
|
```bash
|
|
652
652
|
neon auth --profile work # create it, or sign in again
|
|
653
|
-
neon
|
|
654
|
-
neon
|
|
653
|
+
neon profiles list
|
|
654
|
+
neon profiles remove work
|
|
655
655
|
```
|
|
656
656
|
|
|
657
657
|
```console
|
|
658
|
-
$ neon
|
|
658
|
+
$ neon profiles list
|
|
659
659
|
Profiles
|
|
660
660
|
┌────────┬─────────┬──────────────────────┬──────────┬──────────────────────────────────────┐
|
|
661
661
|
│ Active │ Name │ Account │ SignedIn │ Credentials │
|
|
@@ -680,14 +680,87 @@ Entries in `profiles.json` are paths, and a path may point anywhere — which is
|
|
|
680
680
|
}
|
|
681
681
|
```
|
|
682
682
|
|
|
683
|
-
`neon
|
|
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.
|
|
684
|
+
|
|
685
|
+
## API keys (`api-keys`)
|
|
686
|
+
|
|
687
|
+
```bash
|
|
688
|
+
neon api-keys list # your account's keys
|
|
689
|
+
neon api-keys list --org-id org-… # an organization's, with scope shown
|
|
690
|
+
|
|
691
|
+
neon api-keys create --name ci # account key
|
|
692
|
+
neon api-keys create --name ci --org-id org-… # organization key
|
|
693
|
+
neon api-keys create --name agent --project-id frosty-… # can access only that project
|
|
694
|
+
|
|
695
|
+
neon api-keys revoke <id> [--org-id org-…]
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
The key is returned once, on create, and cannot be retrieved again. It prints on its own line below the table, so it can be selected in one gesture regardless of terminal width — and `… | tail -1` on stdout yields exactly the key, since both notices go to stderr.
|
|
699
|
+
|
|
700
|
+
```console
|
|
701
|
+
$ neon api-keys create --name agent --project-id proj-in-org
|
|
702
|
+
API key
|
|
703
|
+
┌─────┬───────┬─────────────┐
|
|
704
|
+
│ Id │ Name │ Project │
|
|
705
|
+
├─────┼───────┼─────────────┤
|
|
706
|
+
│ 303 │ agent │ proj-in-org │
|
|
707
|
+
└─────┴───────┴─────────────┘
|
|
708
|
+
|
|
709
|
+
napi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
710
|
+
WARNING: Store this key now: it is not shown again.
|
|
711
|
+
INFO: Limited to proj-in-org: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
`--org-id` and `--project-id` are mutually exclusive. A project-scoped key *is* an organization key, and its organization is looked up from the project rather than chosen separately. With neither flag you get an account key.
|
|
715
|
+
|
|
716
|
+
### Project-scoped keys
|
|
717
|
+
|
|
718
|
+
A key created with `--project-id` bounds what it can reach to one project. Verified against a real scoped key:
|
|
719
|
+
|
|
720
|
+
| Attempt | Result |
|
|
721
|
+
| --- | --- |
|
|
722
|
+
| Read and write its own project | works |
|
|
723
|
+
| Read any other project | `project not found` — not even an existence oracle |
|
|
724
|
+
| `neon projects create` | `project-scoped keys are not allowed to create projects` |
|
|
725
|
+
| `neon projects list` | refused |
|
|
726
|
+
| `neon api-keys create` / `list` | refused (true of any organization key, not only scoped ones) |
|
|
727
|
+
| `neon orgs list` | **works** — it can see the id, name and handle of the organization it belongs to |
|
|
728
|
+
| Anything else about that organization (`GET /organizations/{id}`, members) | refused |
|
|
729
|
+
|
|
730
|
+
It is **not** read-only. Inside its one project it can do everything the API allows, including deleting branches and the project itself — `neon deploy` working at all is proof of that. What it bounds is *reach*, which is what lets you hand it to an agent or a CI job without handing over your account:
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
neon link --project-id frosty-… # once, as yourself — writes .neon
|
|
734
|
+
NEON_API_KEY=napi_… neon deploy # then the agent, reaching only that project
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
`neon link` needs `--project-id` explicitly when using a scoped key: the interactive picker lists your projects, which a scoped key cannot do.
|
|
738
|
+
|
|
739
|
+
`api-keys` deliberately ignores the `.neon` context file, unlike every other project command. Otherwise `neon api-keys create --name ci` inside a linked directory would silently mint a key scoped to that project instead of the account key you asked for. How far a credential reaches comes only from a flag you typed.
|
|
740
|
+
|
|
741
|
+
### Seeing what is scoped
|
|
742
|
+
|
|
743
|
+
```console
|
|
744
|
+
$ neon api-keys list --org-id org-7
|
|
745
|
+
API keys in org-7
|
|
746
|
+
┌─────┬──────────┬────────────────┬──────────────────────┬──────────────────────┬─────────────────────┐
|
|
747
|
+
│ Id │ Name │ Project │ Created At │ Last Used At │ Last Used From Addr │
|
|
748
|
+
├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
|
|
749
|
+
│ 301 │ scoped │ proj-in-org │ 2026-01-02T00:00:00Z │ │ │
|
|
750
|
+
├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
|
|
751
|
+
│ 302 │ org-wide │ (all projects) │ 2026-01-03T00:00:00Z │ 2026-02-03T00:00:00Z │ 203.0.113.9 │
|
|
752
|
+
└─────┴──────────┴────────────────┴──────────────────────┴──────────────────────┴─────────────────────┘
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
`last_used_at` and `last_used_from_addr` are how you spot a key worth revoking.
|
|
684
756
|
|
|
685
757
|
## Commands
|
|
686
758
|
|
|
687
759
|
| Command | Subcommands | Description |
|
|
688
760
|
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
|
|
689
761
|
| [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
|
|
690
|
-
|
|
|
762
|
+
| profiles | `list`, `remove` | Manage named sets of credentials |
|
|
763
|
+
| api-keys | `list`, `create`, `revoke` | Manage API keys |
|
|
691
764
|
| [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
|
|
692
765
|
| [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
|
|
693
766
|
| [me](https://neon.com/docs/reference/cli-me) | | Show current user |
|
package/dist/api.js
CHANGED
|
@@ -111,9 +111,44 @@ function headersToObject(headers) {
|
|
|
111
111
|
});
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Raised by {@link makeTimedFetch} when the CLI's own request timeout fires. Owning the
|
|
116
|
+
* type is what makes the timeout recognisable further up: `@neon/sdk` reports every
|
|
117
|
+
* transport failure as a `NeonNetworkError`, so matching on names or codes cannot tell a
|
|
118
|
+
* timeout from a reset connection.
|
|
119
|
+
*/
|
|
120
|
+
class RequestTimeoutError extends Error {
|
|
121
|
+
constructor(timeoutMs, reason) {
|
|
122
|
+
super(`Request timed out after ${timeoutMs}ms`);
|
|
123
|
+
this.name = "RequestTimeoutError";
|
|
124
|
+
this.reason = reason;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a failure was a request timeout rather than a connectivity problem.
|
|
129
|
+
*
|
|
130
|
+
* Walks the `cause` chain, because the SDK wraps whatever `fetch` threw. Before this, the
|
|
131
|
+
* check only looked at the top-level error's `name` — which is `NeonNetworkError` on every
|
|
132
|
+
* SDK path — so a timeout fell through to the connectivity branch and the user was told to
|
|
133
|
+
* check an internet connection that was working.
|
|
134
|
+
*/
|
|
114
135
|
function isAbortError(err) {
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
let current = err;
|
|
137
|
+
for (let depth = 0; depth < 6 && current != null; depth++) {
|
|
138
|
+
if (current instanceof RequestTimeoutError)
|
|
139
|
+
return true;
|
|
140
|
+
if (current instanceof Error &&
|
|
141
|
+
(current.name === "AbortError" || current.name === "TimeoutError")) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
// `@neon/sdk` classifies its own deadlines and cancellations by kind; the CLI
|
|
145
|
+
// does not set them today, but reading them keeps this correct if it ever does.
|
|
146
|
+
const kind = current.kind;
|
|
147
|
+
if (kind === "timeout" || kind === "aborted")
|
|
148
|
+
return true;
|
|
149
|
+
current = current.cause;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
117
152
|
}
|
|
118
153
|
/**
|
|
119
154
|
* Walk an error's `cause` chain to find the underlying socket/DNS `code` (e.g.
|
|
@@ -182,17 +217,53 @@ function networkError(err) {
|
|
|
182
217
|
* and lightweight debug logging of the request line + response status — the
|
|
183
218
|
* fetch-native replacement for the old `axios-debug-log` wiring.
|
|
184
219
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
220
|
+
/**
|
|
221
|
+
* The largest delay a timer can represent. Above it Node warns
|
|
222
|
+
* (`TimeoutOverflowWarning`) and fires after 1ms instead.
|
|
223
|
+
*/
|
|
224
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
225
|
+
/**
|
|
226
|
+
* Reject a timeout `AbortSignal.timeout` would refuse or silently mistreat.
|
|
227
|
+
*
|
|
228
|
+
* Without this the bad value surfaces as the very failure this classification exists to
|
|
229
|
+
* prevent: `-1`, `NaN`, `Infinity`, fractions and anything above `4294967295` throw
|
|
230
|
+
* `ERR_OUT_OF_RANGE` from inside the fetch wrapper, which is then wrapped as a
|
|
231
|
+
* `NeonNetworkError` and reported as a broken internet connection.
|
|
232
|
+
*
|
|
233
|
+
* `0`, and the band from {@link MAX_TIMER_MS} + 1 up to `4294967295`, are worse still:
|
|
234
|
+
* both are accepted, and both make every request time out immediately — `0` by asking for
|
|
235
|
+
* it, the band because a timer above the signed 32-bit ceiling collapses to 1ms.
|
|
236
|
+
*/
|
|
237
|
+
function validateRequestTimeout(ms) {
|
|
238
|
+
if (!Number.isInteger(ms) || ms < 1 || ms > MAX_TIMER_MS) {
|
|
239
|
+
throw new Error(`requestTimeoutMs must be a whole number of milliseconds between 1 and ${MAX_TIMER_MS}; received ${ms}.`);
|
|
240
|
+
}
|
|
241
|
+
return ms;
|
|
242
|
+
}
|
|
243
|
+
const makeTimedFetch = (requestTimeoutMs) => async (input, init) => {
|
|
244
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
187
245
|
const signal = init?.signal
|
|
188
246
|
? AbortSignal.any([init.signal, timeout])
|
|
189
247
|
: timeout;
|
|
190
248
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
191
249
|
const url = input instanceof Request ? input.url : String(input);
|
|
192
250
|
log.debug("%s %s", method.toUpperCase(), url);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(input, { ...init, signal });
|
|
253
|
+
log.debug("%d %s", response.status, response.statusText);
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
// Our own timeout fired, and we are the only code that knows that: by the
|
|
258
|
+
// time this reaches `networkError` the SDK has wrapped it as a
|
|
259
|
+
// `NeonNetworkError`, and neither its name nor its `cause` chain carries a
|
|
260
|
+
// string code to recognise. Raise something we own instead of leaving the
|
|
261
|
+
// classification to guess.
|
|
262
|
+
if (timeout.aborted && !init?.signal?.aborted) {
|
|
263
|
+
throw new RequestTimeoutError(requestTimeoutMs, err);
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
196
267
|
};
|
|
197
268
|
const RETRY_COUNT = 5;
|
|
198
269
|
const RETRY_DELAY = 3000;
|
|
@@ -246,12 +317,15 @@ async function readJsonBody(response) {
|
|
|
246
317
|
return text;
|
|
247
318
|
}
|
|
248
319
|
}
|
|
249
|
-
export const getApiClient = ({ apiKey, apiHost }) => {
|
|
320
|
+
export const getApiClient = ({ apiKey, apiHost, requestTimeoutMs = REQUEST_TIMEOUT_MS, }) => {
|
|
250
321
|
const baseUrl = apiHost ?? DEFAULT_API_HOST;
|
|
322
|
+
// Shared by the generated client and the low-level `request()` escape hatch, so both
|
|
323
|
+
// paths get the same timeout and the same timeout classification.
|
|
324
|
+
const fetchWithTimeout = makeTimedFetch(validateRequestTimeout(requestTimeoutMs));
|
|
251
325
|
const client = createClient(createConfig({
|
|
252
326
|
auth: () => apiKey,
|
|
253
327
|
baseUrl,
|
|
254
|
-
fetch:
|
|
328
|
+
fetch: fetchWithTimeout,
|
|
255
329
|
headers: { "User-Agent": USER_AGENT },
|
|
256
330
|
}));
|
|
257
331
|
/** Await a raw call, unwrap to a `{ data, status, headers }` envelope, or throw {@link NeonApiError}. */
|
|
@@ -306,7 +380,7 @@ export const getApiClient = ({ apiKey, apiHost }) => {
|
|
|
306
380
|
}
|
|
307
381
|
let response;
|
|
308
382
|
try {
|
|
309
|
-
response = await
|
|
383
|
+
response = await fetchWithTimeout(url, {
|
|
310
384
|
method: params.method,
|
|
311
385
|
headers,
|
|
312
386
|
...(payload !== undefined ? { body: payload } : {}),
|
|
@@ -346,6 +420,20 @@ export const getApiClient = ({ apiKey, apiHost }) => {
|
|
|
346
420
|
getCurrentUserOrganizations: () => call(() => raw.getCurrentUserOrganizations({ client })),
|
|
347
421
|
getAuthDetails: () => call(() => raw.getAuthDetails({ client })),
|
|
348
422
|
getActiveRegions: () => call(() => raw.getActiveRegions({ client })),
|
|
423
|
+
// ─── API keys ────────────────────────────────────────────────────────
|
|
424
|
+
// Account keys reach everything the account can. Org keys can additionally be
|
|
425
|
+
// narrowed to a single project via `project_id`, which is the only way to mint a
|
|
426
|
+
// least-privilege credential — so the org endpoints are not merely the org
|
|
427
|
+
// equivalent of the account ones.
|
|
428
|
+
listApiKeys: () => call(() => raw.listApiKeys({ client })),
|
|
429
|
+
createApiKey: (body) => call(() => raw.createApiKey({ client, body })),
|
|
430
|
+
revokeApiKey: (keyId) => call(() => raw.revokeApiKey({ client, path: { key_id: keyId } })),
|
|
431
|
+
listOrgApiKeys: (orgId) => call(() => raw.listOrgApiKeys({ client, path: { org_id: orgId } })),
|
|
432
|
+
createOrgApiKey: (orgId, body) => call(() => raw.createOrgApiKey({ client, path: { org_id: orgId }, body })),
|
|
433
|
+
revokeOrgApiKey: (orgId, keyId) => call(() => raw.revokeOrgApiKey({
|
|
434
|
+
client,
|
|
435
|
+
path: { org_id: orgId, key_id: keyId },
|
|
436
|
+
})),
|
|
349
437
|
// ─── Projects ────────────────────────────────────────────────────────
|
|
350
438
|
listProjects: (query = {}) => call(() => raw.listProjects({ client, query })),
|
|
351
439
|
listSharedProjects: (query = {}) => call(() => raw.listSharedProjects({
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { isNeonApiError } from "../api.js";
|
|
2
|
+
import { log } from "../log.js";
|
|
3
|
+
import { writer } from "../writer.js";
|
|
4
|
+
const ACCOUNT_FIELDS = [
|
|
5
|
+
"id",
|
|
6
|
+
"name",
|
|
7
|
+
"created_at",
|
|
8
|
+
"last_used_at",
|
|
9
|
+
"last_used_from_addr",
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Table view of an org listing. `project` is the rendered column; the raw `project_id` is
|
|
13
|
+
* what structured output keeps. See {@link list} for why the two differ.
|
|
14
|
+
*/
|
|
15
|
+
const ORG_TABLE_FIELDS = [
|
|
16
|
+
"id",
|
|
17
|
+
"name",
|
|
18
|
+
"project",
|
|
19
|
+
"created_at",
|
|
20
|
+
"last_used_at",
|
|
21
|
+
"last_used_from_addr",
|
|
22
|
+
];
|
|
23
|
+
const ORG_FIELDS = [
|
|
24
|
+
"id",
|
|
25
|
+
"name",
|
|
26
|
+
"project_id",
|
|
27
|
+
"created_at",
|
|
28
|
+
"last_used_at",
|
|
29
|
+
"last_used_from_addr",
|
|
30
|
+
];
|
|
31
|
+
const CREATE_FIELDS = ["id", "name", "key"];
|
|
32
|
+
/** Metadata only: the secret is printed separately so it can be copied cleanly. */
|
|
33
|
+
const CREATE_TABLE_FIELDS = ["id", "name"];
|
|
34
|
+
const CREATE_TABLE_FIELDS_SCOPED = ["id", "name", "project"];
|
|
35
|
+
const CREATE_FIELDS_SCOPED = ["id", "name", "project_id", "key"];
|
|
36
|
+
/** Rendered for an org key that was not narrowed to a project. Table output only. */
|
|
37
|
+
const ALL_PROJECTS = "(all projects)";
|
|
38
|
+
/**
|
|
39
|
+
* "This key should carry no project scope."
|
|
40
|
+
*
|
|
41
|
+
* A symbol rather than a string, because project ids are `^[a-z0-9-]{1,60}$` — any sentinel
|
|
42
|
+
* spelled as a string is a legal project id, and a project genuinely called that would have
|
|
43
|
+
* its correctly-scoped key rejected and revoked.
|
|
44
|
+
*/
|
|
45
|
+
const NO_PROJECT = Symbol("no-project");
|
|
46
|
+
/**
|
|
47
|
+
* A flag is either absent, or exactly one non-empty string. Anything else is an error.
|
|
48
|
+
*
|
|
49
|
+
* Every rejected shape otherwise ends the same way: the flag reads as falsy, the scope check
|
|
50
|
+
* falls through, and an **account** key is minted instead of the narrow one asked for — the
|
|
51
|
+
* worst thing this command can do, so none of them get a lenient reading.
|
|
52
|
+
*
|
|
53
|
+
* - `--project-id ""` — an unset shell variable; empty string, which is falsy.
|
|
54
|
+
* - `--no-project-id` — yargs boolean negation; `false`, which is falsy.
|
|
55
|
+
* - `--project-id a --project-id b` — an array, which would reach the API as `a,b`.
|
|
56
|
+
*
|
|
57
|
+
* A misspelled flag never binds and cannot be seen here; `.strict()` rejects it. Anything
|
|
58
|
+
* after a `--` terminator is handled by {@link noPassthrough}.
|
|
59
|
+
*/
|
|
60
|
+
const single = (name, { required = false } = {}) => (value) => {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
throw new Error(`--${name} was given more than once. Pass it at most once.`);
|
|
65
|
+
}
|
|
66
|
+
// `--no-x` is the negation form and yields `false`, so name it rather than telling
|
|
67
|
+
// the user their value was empty when they never gave one.
|
|
68
|
+
if (value === false) {
|
|
69
|
+
throw new Error(required
|
|
70
|
+
? `--no-${name} is not valid: --${name} is required.`
|
|
71
|
+
: `--no-${name} is not a valid way to skip --${name}. Omit the flag entirely.`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
74
|
+
throw new Error(required
|
|
75
|
+
? `--${name} needs a value.`
|
|
76
|
+
: `--${name} needs a value. Pass one, or omit the flag entirely.`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Refuse arguments after a `--` terminator.
|
|
82
|
+
*
|
|
83
|
+
* The CLI sets `populate--`, so everything past `--` lands in `argv["--"]` where `.strict()`
|
|
84
|
+
* never looks — `create --name x -- --project-id p` would parse cleanly and mint an account
|
|
85
|
+
* key from a line that names the scope flag. No `api-keys` subcommand takes passthrough
|
|
86
|
+
* arguments, so their presence is always a mistake.
|
|
87
|
+
*/
|
|
88
|
+
const noPassthrough = (argv) => {
|
|
89
|
+
const rest = argv["--"];
|
|
90
|
+
if (Array.isArray(rest) && rest.length > 0) {
|
|
91
|
+
throw new Error(`api-keys takes no arguments after \`--\`, and options placed there are ignored rather than applied. Remove the \`--\`.`);
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
95
|
+
export const command = "api-keys";
|
|
96
|
+
export const aliases = ["api-key"];
|
|
97
|
+
export const describe = "Manage API keys";
|
|
98
|
+
export const builder = (argv) => argv
|
|
99
|
+
.usage("$0 api-keys <sub-command> [options]")
|
|
100
|
+
.command("list", "List API keys for your account, or for an organization", (yargs) => yargs
|
|
101
|
+
.options({
|
|
102
|
+
"org-id": {
|
|
103
|
+
describe: "List the organization's keys instead of your account's",
|
|
104
|
+
type: "string",
|
|
105
|
+
coerce: single("org-id"),
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
.strict()
|
|
109
|
+
.check(noPassthrough), async (args) => await list(args))
|
|
110
|
+
.command("create", "Create an API key. The key is shown once and cannot be retrieved again", (yargs) => yargs
|
|
111
|
+
.options({
|
|
112
|
+
name: {
|
|
113
|
+
describe: "A name to identify the key later",
|
|
114
|
+
type: "string",
|
|
115
|
+
demandOption: true,
|
|
116
|
+
coerce: single("name", { required: true }),
|
|
117
|
+
},
|
|
118
|
+
"org-id": {
|
|
119
|
+
describe: "Create a key for this organization instead of your account",
|
|
120
|
+
type: "string",
|
|
121
|
+
coerce: single("org-id"),
|
|
122
|
+
},
|
|
123
|
+
"project-id": {
|
|
124
|
+
describe: "Create a key that can access only this project. Its organization is looked up from the project",
|
|
125
|
+
type: "string",
|
|
126
|
+
coerce: single("project-id"),
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
// A key scoped to a project is already an organization key, so naming
|
|
130
|
+
// both is contradictory rather than redundant — the org is derived from
|
|
131
|
+
// the project and cannot be chosen independently.
|
|
132
|
+
.conflicts("org-id", "project-id")
|
|
133
|
+
.strict()
|
|
134
|
+
.check(noPassthrough), async (args) => await create(args))
|
|
135
|
+
.command("revoke <id>", "Revoke an API key. Anything using it stops working immediately", (yargs) => yargs
|
|
136
|
+
.positional("id", {
|
|
137
|
+
describe: "The API key id, from `api-keys list`",
|
|
138
|
+
// Deliberately not `type: "number"`. That coerces before `coerce`
|
|
139
|
+
// runs, so an unparseable id arrives as NaN and the error can only
|
|
140
|
+
// echo `NaN` — a JavaScript artifact the user never typed. Taking
|
|
141
|
+
// the raw string lets the message name what they actually passed,
|
|
142
|
+
// and stops NaN reaching the API, which answers "not found" and
|
|
143
|
+
// blames the key rather than the input.
|
|
144
|
+
type: "string",
|
|
145
|
+
demandOption: true,
|
|
146
|
+
coerce: (value) => {
|
|
147
|
+
// Digits only, checked before Number(): `Number("0x65")` is 101
|
|
148
|
+
// and `Number("1e3")` is 1000, so either would revoke a key the
|
|
149
|
+
// user never named while the message promises "a numeric id".
|
|
150
|
+
const id = Number(value);
|
|
151
|
+
if (typeof value !== "string" ||
|
|
152
|
+
!/^\d+$/.test(value.trim()) ||
|
|
153
|
+
!Number.isSafeInteger(id) ||
|
|
154
|
+
id <= 0) {
|
|
155
|
+
throw new Error(`api-keys revoke needs a numeric key id, from \`neon api-keys list\`. Got \`${String(value)}\`.`);
|
|
156
|
+
}
|
|
157
|
+
return id;
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
.options({
|
|
161
|
+
"org-id": {
|
|
162
|
+
describe: "Revoke an organization key instead of an account key",
|
|
163
|
+
type: "string",
|
|
164
|
+
coerce: single("org-id"),
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
.strict()
|
|
168
|
+
.check(noPassthrough), async (args) => await revoke(args))
|
|
169
|
+
.demandCommand(1, "Run `neon api-keys --help` to see the subcommands.");
|
|
170
|
+
export const handler = (args) => args;
|
|
171
|
+
const list = async (props) => {
|
|
172
|
+
const out = writer(props);
|
|
173
|
+
if (props.orgId) {
|
|
174
|
+
const { data } = await props.apiClient.listOrgApiKeys(props.orgId);
|
|
175
|
+
// `writeTable` drops any column empty in every row, so an all-absent `project_id`
|
|
176
|
+
// would take the whole column with it — hiding the answer exactly when it is "none
|
|
177
|
+
// of them are scoped". Fill it in for the table, but leave structured output alone:
|
|
178
|
+
// json/yaml serialize the whole object, so a synthetic field there would change the
|
|
179
|
+
// machine-readable shape and duplicate `project_id` under a second name.
|
|
180
|
+
if (props.output === "table") {
|
|
181
|
+
out.write(data.map((key) => ({
|
|
182
|
+
...key,
|
|
183
|
+
project: key.project_id ?? ALL_PROJECTS,
|
|
184
|
+
})), {
|
|
185
|
+
fields: ORG_TABLE_FIELDS,
|
|
186
|
+
title: `API keys in ${props.orgId}`,
|
|
187
|
+
emptyMessage: `No API keys in ${props.orgId}.`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
out.write(data, {
|
|
192
|
+
fields: ORG_FIELDS,
|
|
193
|
+
title: `API keys in ${props.orgId}`,
|
|
194
|
+
emptyMessage: `No API keys in ${props.orgId}.`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
out.end();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const { data } = await props.apiClient.listApiKeys();
|
|
201
|
+
out.write(data, {
|
|
202
|
+
fields: ACCOUNT_FIELDS,
|
|
203
|
+
title: "Account API keys",
|
|
204
|
+
emptyMessage: "You have no account API keys.",
|
|
205
|
+
});
|
|
206
|
+
out.end();
|
|
207
|
+
// Organization keys live on a different endpoint, so a heading of plain "API keys"
|
|
208
|
+
// would claim to be everything while showing only half.
|
|
209
|
+
if (props.output === "table") {
|
|
210
|
+
log.info("Organization keys are listed separately: neon api-keys list --org-id <org> (see `neon orgs list`).");
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const create = async (props) => {
|
|
214
|
+
const { name, projectId, orgId } = props;
|
|
215
|
+
// Neither flag: an account key, matching `POST /api_keys`. `api-keys` is exempt from
|
|
216
|
+
// `.neon` enrichment (see `isApiKeysCommand`), so reaching this branch means the user
|
|
217
|
+
// really did ask for account scope rather than inheriting a checked-out project.
|
|
218
|
+
if (!projectId && !orgId) {
|
|
219
|
+
const { data } = await props.apiClient.createApiKey({ key_name: name });
|
|
220
|
+
await assertUsable(props, data, { orgId: null, expect: NO_PROJECT });
|
|
221
|
+
report(props, data, CREATE_FIELDS, CREATE_TABLE_FIELDS);
|
|
222
|
+
// The only key here that reaches everything, and the only one that used to say
|
|
223
|
+
// nothing about its reach.
|
|
224
|
+
log.warning("This key reaches everything your account can, in every organization. Pass --org-id or --project-id to narrow it.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!projectId && orgId) {
|
|
228
|
+
const { data } = await props.apiClient.createOrgApiKey(orgId, {
|
|
229
|
+
key_name: name,
|
|
230
|
+
});
|
|
231
|
+
await assertUsable(props, data, { orgId, expect: NO_PROJECT });
|
|
232
|
+
report(props, data, CREATE_FIELDS, CREATE_TABLE_FIELDS);
|
|
233
|
+
log.warning("This key reaches every project in %s, including ones created later. Pass --project-id instead to restrict it to one.", orgId);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const scopeTo = projectId;
|
|
237
|
+
// Project-scoped keys exist only on the organization endpoint, so an org is required.
|
|
238
|
+
// Resolve it from the project rather than asking for both: `--project-id` alone would
|
|
239
|
+
// otherwise fail for a reason that isn't visible from the command line.
|
|
240
|
+
const resolvedOrgId = await orgIdForProject(props, scopeTo);
|
|
241
|
+
const { data } = await props.apiClient.createOrgApiKey(resolvedOrgId, {
|
|
242
|
+
key_name: name,
|
|
243
|
+
project_id: scopeTo,
|
|
244
|
+
});
|
|
245
|
+
// `project_id` is optional on the response. Printing a key and calling it scoped
|
|
246
|
+
// without checking would hand over a credential whose reach we never confirmed — the
|
|
247
|
+
// one thing this command must not get wrong.
|
|
248
|
+
await assertUsable(props, data, {
|
|
249
|
+
orgId: resolvedOrgId,
|
|
250
|
+
expect: scopeTo,
|
|
251
|
+
});
|
|
252
|
+
report(props, data, CREATE_FIELDS_SCOPED, CREATE_TABLE_FIELDS_SCOPED);
|
|
253
|
+
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.", scopeTo);
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Print the issued key.
|
|
257
|
+
*
|
|
258
|
+
* In a terminal the secret goes on its own line rather than into a table cell: `cli-table`
|
|
259
|
+
* neither wraps nor truncates, so a 50-odd character key makes the row wider than most
|
|
260
|
+
* terminals and the wrapped remainder ends up beside box-drawing characters. Since this is
|
|
261
|
+
* the only time the key is ever shown, it has to be selectable in one gesture. Structured
|
|
262
|
+
* output keeps the key in the object, where a script expects it.
|
|
263
|
+
*/
|
|
264
|
+
const report = (props, data, fields, tableFields) => {
|
|
265
|
+
const out = writer(props);
|
|
266
|
+
if (props.output === "table") {
|
|
267
|
+
// `project` mirrors the column name `list` uses. Added here rather than by the
|
|
268
|
+
// caller so structured output keeps the API's own `project_id` and gains no
|
|
269
|
+
// duplicate under a second name.
|
|
270
|
+
const row = typeof data.project_id === "string"
|
|
271
|
+
? { ...data, project: data.project_id }
|
|
272
|
+
: data;
|
|
273
|
+
out.write(row, {
|
|
274
|
+
fields: tableFields,
|
|
275
|
+
title: "API key",
|
|
276
|
+
});
|
|
277
|
+
out.end();
|
|
278
|
+
// Blank line so the key is visually detached from the table border, and so
|
|
279
|
+
// `| tail -1` on stdout yields exactly the key (both notices go to stderr).
|
|
280
|
+
out.text(`\n${data.key}\n`);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
out.write(data, { fields: fields, title: "API key" });
|
|
284
|
+
out.end();
|
|
285
|
+
}
|
|
286
|
+
log.warning("Store this key now: it is not shown again.");
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Refuse to report a key that isn't what we asked for, and take it back.
|
|
290
|
+
*
|
|
291
|
+
* Two ways a 2xx can still be wrong: no `key` in the body, which leaves a live credential
|
|
292
|
+
* the user can never see or use; and a `project_id` that doesn't match the requested
|
|
293
|
+
* project, which would mean announcing a scope the key does not have. Both withdraw the key
|
|
294
|
+
* before throwing.
|
|
295
|
+
*
|
|
296
|
+
* The thrown message states whether the withdrawal actually succeeded. Saying "the key has
|
|
297
|
+
* been revoked" when the revoke itself failed would be worse than saying nothing — it would
|
|
298
|
+
* leave an unverified credential live while telling the user it is gone.
|
|
299
|
+
*/
|
|
300
|
+
const assertUsable = async (props, data, scope) => {
|
|
301
|
+
// `expect` is always stated, never inferred from a missing field: "no project" has to be
|
|
302
|
+
// checked as deliberately as an exact project, or a key that came back narrower than
|
|
303
|
+
// requested would be reported as reaching the whole organization.
|
|
304
|
+
const wanted = scope.expect === NO_PROJECT ? undefined : scope.expect;
|
|
305
|
+
const problem = typeof data.key !== "string" || data.key.trim() === ""
|
|
306
|
+
? "Neon returned no key."
|
|
307
|
+
: data.project_id !== wanted
|
|
308
|
+
? `Neon returned a key scoped to ${data.project_id ?? "nothing"} rather than ${wanted ?? "the whole organization"}.`
|
|
309
|
+
: null;
|
|
310
|
+
if (!problem)
|
|
311
|
+
return;
|
|
312
|
+
const withdrawn = await withdraw(props, scope.orgId, data.id);
|
|
313
|
+
throw new Error(`${problem} ${withdrawn
|
|
314
|
+
? "The key has been revoked; nothing was issued."
|
|
315
|
+
: `The key could NOT be revoked and may still be live${data.id === undefined
|
|
316
|
+
? ""
|
|
317
|
+
: `. Remove it with \`neon api-keys revoke ${data.id}${scope.orgId
|
|
318
|
+
? ` --org-id ${scope.orgId}`
|
|
319
|
+
: ""}\``}.`}`);
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Revoke, turning the most likely mistake into a usable message.
|
|
323
|
+
*
|
|
324
|
+
* Account and organization keys live on different endpoints, and `api-keys list --org-id X`
|
|
325
|
+
* shows ids that the account endpoint cannot see. Copying one and forgetting the flag — or
|
|
326
|
+
* leaving it on for an account key — is the easy error, and a bare "API key not found" sends
|
|
327
|
+
* the user looking for a deleted key rather than a misplaced flag.
|
|
328
|
+
*
|
|
329
|
+
* Only the key id is worth second-guessing here: an org id that is wrong or not yours does
|
|
330
|
+
* not reach this path at all, because the API answers "not an organization member" (verified
|
|
331
|
+
* against production) rather than a 404.
|
|
332
|
+
*/
|
|
333
|
+
const revokeOrExplain = async (props) => {
|
|
334
|
+
try {
|
|
335
|
+
return props.orgId
|
|
336
|
+
? await props.apiClient.revokeOrgApiKey(props.orgId, props.id)
|
|
337
|
+
: await props.apiClient.revokeApiKey(props.id);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
341
|
+
throw new Error(props.orgId
|
|
342
|
+
? `No API key with id ${props.id} in ${props.orgId}. If it is one of your account's own keys, drop --org-id.`
|
|
343
|
+
: `No account API key with id ${props.id}. If it belongs to an organization, pass --org-id. Organization keys are not visible to your account.`);
|
|
344
|
+
}
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
/** Best-effort withdrawal of a key we are refusing to report. Never throws. */
|
|
349
|
+
const withdraw = async (props, orgId, keyId) => {
|
|
350
|
+
if (!Number.isSafeInteger(keyId) || keyId <= 0)
|
|
351
|
+
return false;
|
|
352
|
+
try {
|
|
353
|
+
const { data } = orgId
|
|
354
|
+
? await props.apiClient.revokeOrgApiKey(orgId, keyId)
|
|
355
|
+
: await props.apiClient.revokeApiKey(keyId);
|
|
356
|
+
// Check which key the response names: a `revoked: true` for some other id is not
|
|
357
|
+
// evidence that the one we issued is gone.
|
|
358
|
+
return data.revoked === true && data.id === keyId;
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const revoke = async (props) => {
|
|
366
|
+
const out = writer(props);
|
|
367
|
+
const { data } = await revokeOrExplain(props);
|
|
368
|
+
out.write(data, {
|
|
369
|
+
fields: ["id", "name", "revoked", "last_used_at"],
|
|
370
|
+
title: "API key",
|
|
371
|
+
});
|
|
372
|
+
out.end();
|
|
373
|
+
};
|
|
374
|
+
/**
|
|
375
|
+
* The organization a project belongs to. A project outside any organization cannot have a
|
|
376
|
+
* scoped key — the endpoint that accepts `project_id` is org-only — so that case fails here
|
|
377
|
+
* with the reason, rather than as a 404 from a URL the user never typed.
|
|
378
|
+
*/
|
|
379
|
+
const orgIdForProject = async (props, projectId) => {
|
|
380
|
+
let orgId;
|
|
381
|
+
try {
|
|
382
|
+
const { data: { project }, } = await props.apiClient.getProject(projectId);
|
|
383
|
+
orgId = project.org_id;
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
387
|
+
throw new Error(projectId.startsWith("org-")
|
|
388
|
+
? `Project ${projectId} not found. That looks like an organization id. Pass it as --org-id instead.`
|
|
389
|
+
: `Project ${projectId} not found. Check the id with \`neon projects list\`.`);
|
|
390
|
+
}
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
if (!orgId) {
|
|
394
|
+
throw new Error(`Project ${projectId} does not belong to an organization, so it cannot have a project-scoped API key. Create an account key by omitting --project-id.`);
|
|
395
|
+
}
|
|
396
|
+
return orgId;
|
|
397
|
+
};
|
package/dist/commands/config.js
CHANGED
|
@@ -5,7 +5,7 @@ import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranch
|
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
import { getApiClient } from "../api.js";
|
|
7
7
|
import { toNeonConfigView } from "../config_format.js";
|
|
8
|
-
import { FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, NEON_SERVICES, NO_SERVICES, parseServices, REQUIRED_PACKAGES, renderNeonConfig, } from "../config_template.js";
|
|
8
|
+
import { FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, NEON_SERVICES, NO_SERVICES, parseServices, REQUIRED_PACKAGES, renderNeonConfig, renderNeonConfigFromView, } from "../config_template.js";
|
|
9
9
|
import { contextBranch, readContextFile } from "../context.js";
|
|
10
10
|
import { isCi } from "../env.js";
|
|
11
11
|
import { loadEnvFileIntoProcess } from "../env_file.js";
|
|
@@ -143,10 +143,39 @@ const scaffoldFunction = (cwd) => {
|
|
|
143
143
|
writeFileSync(path, FUNCTION_TEMPLATE);
|
|
144
144
|
log.info("Created %s — the source of the %s function.", FUNCTION_FILENAME, FUNCTION_SLUG);
|
|
145
145
|
};
|
|
146
|
+
/**
|
|
147
|
+
* Read a branch's live state and render it as a `neon.ts`. The read goes through the same
|
|
148
|
+
* {@link liveConfigView} as `config status`, so a seeded policy declares exactly what
|
|
149
|
+
* `config status --config-json` reports — including what it cannot report (see
|
|
150
|
+
* {@link renderNeonConfigFromView}).
|
|
151
|
+
*/
|
|
152
|
+
const seedFromBranch = async (props) => {
|
|
153
|
+
const { apiClient, projectId } = props;
|
|
154
|
+
if (!apiClient || !projectId) {
|
|
155
|
+
throw new Error("--from-branch needs a project. Pass --project-id, or run `neon link` to pin one in .neon.");
|
|
156
|
+
}
|
|
157
|
+
const ref = await resolveBranchRef({
|
|
158
|
+
apiClient,
|
|
159
|
+
projectId,
|
|
160
|
+
...(props.branch !== undefined ? { branch: props.branch } : {}),
|
|
161
|
+
});
|
|
162
|
+
if (ref.usedDefault) {
|
|
163
|
+
log.info("No branch pinned or passed — seeding from the project's default branch %s.", ref.branchName);
|
|
164
|
+
}
|
|
165
|
+
const { live, view } = await liveConfigView({
|
|
166
|
+
projectId,
|
|
167
|
+
branchId: ref.branchId,
|
|
168
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
169
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
170
|
+
...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
|
|
171
|
+
});
|
|
172
|
+
const rendered = renderNeonConfigFromView(view, live.branch.name);
|
|
173
|
+
return { ...rendered, branchName: live.branch.name };
|
|
174
|
+
};
|
|
146
175
|
/**
|
|
147
176
|
* Scaffold a `neon.ts` policy and make sure the Neon config packages are
|
|
148
177
|
* installed, so a project can go straight to `neon config plan` / `apply`.
|
|
149
|
-
*
|
|
178
|
+
* Local-only unless `--from-branch` is set (see {@link isConfigInit}).
|
|
150
179
|
*/
|
|
151
180
|
export const initCmd = async (props) => {
|
|
152
181
|
const cwd = props.cwd ?? process.cwd();
|
|
@@ -158,6 +187,16 @@ export const initCmd = async (props) => {
|
|
|
158
187
|
if (existing) {
|
|
159
188
|
log.info("Found an existing %s — leaving it untouched.", existing);
|
|
160
189
|
}
|
|
190
|
+
else if (props.fromBranch) {
|
|
191
|
+
const { source, seeded, branchName } = await seedFromBranch(props);
|
|
192
|
+
writeFileSync(join(cwd, "neon.ts"), source);
|
|
193
|
+
if (seeded) {
|
|
194
|
+
log.info("Created neon.ts from the live state of %s.", branchName);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
log.info("%s declares no services and no branch settings — created neon.ts with the starter policy instead.", branchName);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
161
200
|
else {
|
|
162
201
|
const services = await resolveServices(props);
|
|
163
202
|
writeFileSync(join(cwd, "neon.ts"), renderNeonConfig(services));
|
|
@@ -251,10 +290,47 @@ export const builder = (argv) => argv
|
|
|
251
290
|
"terminal, starter policy in CI or without a TTY.",
|
|
252
291
|
type: "string",
|
|
253
292
|
},
|
|
293
|
+
"from-branch": {
|
|
294
|
+
describe: "Seed neon.ts from a branch's live Neon state instead of asking. Uses the " +
|
|
295
|
+
"branch pinned in .neon, or --branch <name|id>, or the project's default " +
|
|
296
|
+
"branch. The only mode of `config init` that calls the Neon API.",
|
|
297
|
+
type: "boolean",
|
|
298
|
+
// No `default`: yargs counts a defaulted key as provided, so
|
|
299
|
+
// `default: false` makes `conflicts` reject every `--services` run.
|
|
300
|
+
conflicts: "services",
|
|
301
|
+
},
|
|
254
302
|
}), (args) => initCmd(args));
|
|
255
303
|
export const handler = (args) => {
|
|
256
304
|
return args;
|
|
257
305
|
};
|
|
306
|
+
/**
|
|
307
|
+
* A branch's live state, plus that state projected into the `neon.ts`-shaped
|
|
308
|
+
* {@link NeonConfigView}. Shared by `config status` and `config init --from-branch` so both
|
|
309
|
+
* read the branch through one path: what `status --config-json` prints is exactly what
|
|
310
|
+
* `init --from-branch` writes.
|
|
311
|
+
*
|
|
312
|
+
* The pulled `config` carries the branch's tuning inside a closure that JSON can't render, so
|
|
313
|
+
* it is resolved against the live branch target first.
|
|
314
|
+
*/
|
|
315
|
+
const liveConfigView = async (opts) => {
|
|
316
|
+
const live = await inspect({
|
|
317
|
+
projectId: opts.projectId,
|
|
318
|
+
branchId: opts.branchId,
|
|
319
|
+
...(opts.apiKey ? { apiKey: opts.apiKey } : {}),
|
|
320
|
+
...(opts.apiHost ? { apiHost: opts.apiHost } : {}),
|
|
321
|
+
...(opts.runtimeApi ? { api: opts.runtimeApi } : {}),
|
|
322
|
+
});
|
|
323
|
+
const resolved = resolveConfig(live.config, {
|
|
324
|
+
name: live.branch.name,
|
|
325
|
+
id: live.branch.id,
|
|
326
|
+
exists: true,
|
|
327
|
+
isDefault: live.branch.isDefault,
|
|
328
|
+
isProtected: live.branch.protected,
|
|
329
|
+
...(live.branch.parent ? { parentId: live.branch.parent } : {}),
|
|
330
|
+
...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
|
|
331
|
+
});
|
|
332
|
+
return { live, view: toNeonConfigView(resolved, live.preview) };
|
|
333
|
+
};
|
|
258
334
|
const loadConfig = async (props) => {
|
|
259
335
|
// Load the optional --env file FIRST so a `neon.ts` whose function `env` values read
|
|
260
336
|
// `process.env.X` sees them. Must happen before the policy module is imported/evaluated.
|
|
@@ -289,27 +365,13 @@ export const status = async (props) => {
|
|
|
289
365
|
if (!props.configJson) {
|
|
290
366
|
announceTargetBranch(props, branch, "Inspecting branch");
|
|
291
367
|
}
|
|
292
|
-
const
|
|
293
|
-
const live = await inspect({
|
|
368
|
+
const { live, view: configView } = await liveConfigView({
|
|
294
369
|
projectId: props.projectId,
|
|
295
|
-
branchId,
|
|
370
|
+
branchId: branch.branchId,
|
|
296
371
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
297
372
|
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
298
|
-
...(props.runtimeApi ? {
|
|
299
|
-
});
|
|
300
|
-
// The pulled `config` carries the branch's tuning inside a closure that JSON can't
|
|
301
|
-
// render. Resolve it against the live branch target to get the concrete settings, then
|
|
302
|
-
// project both that and the separately-pulled preview state into a neon.ts-shaped view.
|
|
303
|
-
const resolved = resolveConfig(live.config, {
|
|
304
|
-
name: live.branch.name,
|
|
305
|
-
id: live.branch.id,
|
|
306
|
-
exists: true,
|
|
307
|
-
isDefault: live.branch.isDefault,
|
|
308
|
-
isProtected: live.branch.protected,
|
|
309
|
-
...(live.branch.parent ? { parentId: live.branch.parent } : {}),
|
|
310
|
-
...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
|
|
373
|
+
...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
|
|
311
374
|
});
|
|
312
|
-
const configView = toNeonConfigView(resolved, live.preview);
|
|
313
375
|
// `--config-json`: emit just the neon.ts-shaped config to stdout (script-friendly,
|
|
314
376
|
// copy-paste-able), regardless of the global --output.
|
|
315
377
|
if (props.configJson) {
|
package/dist/commands/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as api from "./api.js";
|
|
2
|
+
import * as apiKeys from "./api_keys.js";
|
|
2
3
|
import * as auth from "./auth.js";
|
|
3
4
|
import * as bootstrap from "./bootstrap.js";
|
|
4
5
|
import * as branches from "./branches.js";
|
|
@@ -32,6 +33,7 @@ import * as vpcEndpoints from "./vpc_endpoints.js";
|
|
|
32
33
|
export default [
|
|
33
34
|
auth,
|
|
34
35
|
profile,
|
|
36
|
+
apiKeys,
|
|
35
37
|
api,
|
|
36
38
|
users,
|
|
37
39
|
orgs,
|
package/dist/commands/profile.js
CHANGED
|
@@ -6,11 +6,11 @@ import { isCi } from "../env.js";
|
|
|
6
6
|
import { log } from "../log.js";
|
|
7
7
|
import { DEFAULT_PROFILE, listProfiles, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, } from "../profiles.js";
|
|
8
8
|
import { writer } from "../writer.js";
|
|
9
|
-
export const command = "
|
|
10
|
-
export const aliases = ["
|
|
9
|
+
export const command = "profiles";
|
|
10
|
+
export const aliases = ["profile"];
|
|
11
11
|
export const describe = "Manage named sets of Neon credentials";
|
|
12
12
|
export const builder = (argv) => argv
|
|
13
|
-
.usage("$0
|
|
13
|
+
.usage("$0 profiles <sub-command> [options]")
|
|
14
14
|
.command("list", "List profiles, the account each holds, and where its credentials live", (y) => y, async (args) => await list(args))
|
|
15
15
|
.command("remove <name>", "Revoke a profile's token and remove it", (y) => y
|
|
16
16
|
.positional("name", {
|
|
@@ -24,7 +24,7 @@ export const builder = (argv) => argv
|
|
|
24
24
|
type: "boolean",
|
|
25
25
|
default: false,
|
|
26
26
|
}), async (args) => await remove(args))
|
|
27
|
-
.demandCommand(1, "Run `neon
|
|
27
|
+
.demandCommand(1, "Run `neon profiles --help` to see the subcommands.");
|
|
28
28
|
export const handler = (_args) => {
|
|
29
29
|
/* subcommands only */
|
|
30
30
|
};
|
package/dist/config_template.js
CHANGED
|
@@ -60,22 +60,43 @@ export const parseServices = (raw) => {
|
|
|
60
60
|
}
|
|
61
61
|
return NEON_SERVICES.filter((service) => names.includes(service));
|
|
62
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* One indentation level in the emitted `neon.ts`. Two spaces, which is what every renderer
|
|
65
|
+
* here produces and what `config_template.format.test.ts` holds them to.
|
|
66
|
+
*/
|
|
67
|
+
const INDENT = " ";
|
|
68
|
+
/**
|
|
69
|
+
* Prefix each line with `level` indentation levels. Nesting is expressed as a number at the
|
|
70
|
+
* one place that knows the structure, rather than as literal spaces at every push site — a
|
|
71
|
+
* miscounted space is otherwise invisible in review and only shows up in a user's file.
|
|
72
|
+
*/
|
|
73
|
+
const at = (level, ...lines) => lines.map((line) => INDENT.repeat(level) + line);
|
|
74
|
+
/** Wrap `body` in an object-literal block named `key`, indented from `level`. */
|
|
75
|
+
const block = (level, key, body) => [
|
|
76
|
+
...at(level, `${key}: {`),
|
|
77
|
+
...body,
|
|
78
|
+
...at(level, "},"),
|
|
79
|
+
];
|
|
63
80
|
/** The `preview` block for the selected services, or "" when none of them is a preview feature. */
|
|
64
81
|
const renderPreview = (services) => {
|
|
65
82
|
const lines = [];
|
|
66
83
|
if (services.includes("ai-gateway")) {
|
|
67
|
-
lines.push("
|
|
84
|
+
lines.push(...at(2, "aiGateway: true,"));
|
|
68
85
|
}
|
|
69
86
|
if (services.includes("functions")) {
|
|
70
|
-
lines.push(
|
|
87
|
+
lines.push(...block(2, "functions", [
|
|
88
|
+
...at(3, `${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`),
|
|
89
|
+
]));
|
|
71
90
|
}
|
|
72
91
|
if (services.includes("storage")) {
|
|
73
|
-
lines.push("
|
|
92
|
+
lines.push(...block(2, "buckets", [
|
|
93
|
+
...at(3, `// "private" is the default; use "public_read" for anonymous reads`, `${BUCKET_NAME}: { access: "private" },`),
|
|
94
|
+
]));
|
|
74
95
|
}
|
|
75
96
|
if (lines.length === 0) {
|
|
76
97
|
return "";
|
|
77
98
|
}
|
|
78
|
-
return `${
|
|
99
|
+
return `${block(1, "preview", lines).join("\n")}\n`;
|
|
79
100
|
};
|
|
80
101
|
/**
|
|
81
102
|
* Render the `neon.ts` policy `config init` writes. With no services this is the starter
|
|
@@ -104,6 +125,107 @@ ${renderPreview(services)} // Branch policy: per-branch tuning
|
|
|
104
125
|
},
|
|
105
126
|
});
|
|
106
127
|
`;
|
|
128
|
+
/** Render a scalar the way it has to appear in TypeScript source. */
|
|
129
|
+
const renderScalar = (value) => typeof value === "string" ? `"${value}"` : String(value);
|
|
130
|
+
/**
|
|
131
|
+
* Render an object key. Live names are not identifiers: a Neon bucket may be called
|
|
132
|
+
* `smoke-uploads`, which as a bare key is a subtraction and a syntax error. Anything that
|
|
133
|
+
* isn't a plain identifier gets quoted.
|
|
134
|
+
*/
|
|
135
|
+
const renderKey = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
136
|
+
/**
|
|
137
|
+
* The `branch` closure for a policy seeded from live state, or "" when the branch carries no
|
|
138
|
+
* tuning worth declaring.
|
|
139
|
+
*
|
|
140
|
+
* `protected` is read but never declared: it is a fact about one branch, while a policy
|
|
141
|
+
* `protected` applies to every branch the policy runs against. It becomes a comment instead.
|
|
142
|
+
*/
|
|
143
|
+
const renderSeededBranch = (view) => {
|
|
144
|
+
const settings = [];
|
|
145
|
+
if (view.branch?.parent !== undefined) {
|
|
146
|
+
settings.push(...at(2, `parent: ${renderScalar(view.branch.parent)},`));
|
|
147
|
+
}
|
|
148
|
+
if (view.branch?.ttl !== undefined) {
|
|
149
|
+
settings.push(...at(2, `ttl: ${renderScalar(view.branch.ttl)},`));
|
|
150
|
+
}
|
|
151
|
+
const compute = view.branch?.postgres?.computeSettings;
|
|
152
|
+
const computeFields = Object.entries(compute ?? {}).filter(([, value]) => value !== undefined);
|
|
153
|
+
if (computeFields.length > 0) {
|
|
154
|
+
settings.push(...block(2, "postgres", [
|
|
155
|
+
...block(3, "computeSettings", computeFields.flatMap(([field, value]) => at(4, `${field}: ${renderScalar(value)},`))),
|
|
156
|
+
]));
|
|
157
|
+
}
|
|
158
|
+
if (settings.length === 0) {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
// An arrow returning an object literal, so the closing line is `}),` rather than `},`.
|
|
162
|
+
return `${[...at(1, "branch: () => ({"), ...settings, ...at(1, "}),")].join("\n")}\n`;
|
|
163
|
+
};
|
|
164
|
+
/** The `preview` block for a policy seeded from live state. */
|
|
165
|
+
const renderSeededPreview = (view, branchName) => {
|
|
166
|
+
const lines = [];
|
|
167
|
+
const buckets = Object.entries(view.preview?.buckets ?? {});
|
|
168
|
+
if (buckets.length > 0) {
|
|
169
|
+
lines.push(...block(2, "buckets", buckets.flatMap(([name, bucket]) => at(3, `${renderKey(name)}: { access: "${bucket.access}" },`))));
|
|
170
|
+
}
|
|
171
|
+
// A deployed function cannot be declared from live state: `source` is a path in the
|
|
172
|
+
// user's project and the branch only knows the uploaded bundle. Listing the slugs as a
|
|
173
|
+
// commented-out block is the most a read-back can honestly produce.
|
|
174
|
+
const functions = Object.entries(view.preview?.functions ?? {});
|
|
175
|
+
if (functions.length > 0) {
|
|
176
|
+
lines.push(...at(2, `// ${branchName} has ${functions.length} deployed function${functions.length === 1 ? "" : "s"}.`, "// Declaring one needs the local source path, which the branch does not know:", "// functions: {", ...functions.map(([slug, fn]) => `// ${renderKey(slug)}: { name: "${fn.name}", source: "./${slug}.ts" },`), "// },"));
|
|
177
|
+
}
|
|
178
|
+
if (lines.length === 0) {
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
return `${block(1, "preview", lines).join("\n")}\n`;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Render a `neon.ts` from a branch's live state (`config init --from-branch`).
|
|
185
|
+
*
|
|
186
|
+
* Only what the branch can actually report is declared. Three things are deliberately
|
|
187
|
+
* absent, each for its own reason:
|
|
188
|
+
*
|
|
189
|
+
* - **The AI Gateway** has no branch-level enabled state to read — it is always available and
|
|
190
|
+
* credential-gated — so `pullConfig` cannot tell whether a policy would enable it.
|
|
191
|
+
* - **Functions** cannot round-trip (no `source` path on the remote); they are listed as a
|
|
192
|
+
* commented-out block.
|
|
193
|
+
* - **`protected`** is branch state rather than policy intent, so it is reported as a comment.
|
|
194
|
+
*
|
|
195
|
+
* A branch with nothing to report (no services, no tuning) renders the starter policy rather
|
|
196
|
+
* than an empty `defineConfig({})`: seeding found nothing, and the caller says so.
|
|
197
|
+
*/
|
|
198
|
+
export const renderNeonConfigFromView = (view, branchName) => {
|
|
199
|
+
const services = [
|
|
200
|
+
view.auth ? " auth: true," : "",
|
|
201
|
+
view.dataApi ? " dataApi: true," : "",
|
|
202
|
+
].filter((line) => line !== "");
|
|
203
|
+
const preview = renderSeededPreview(view, branchName);
|
|
204
|
+
const branch = renderSeededBranch(view);
|
|
205
|
+
if (services.length === 0 && preview === "" && branch === "") {
|
|
206
|
+
return { source: renderNeonConfig([]), seeded: false };
|
|
207
|
+
}
|
|
208
|
+
const protectedNote = view.branch?.protected
|
|
209
|
+
? `// ${branchName} is protected on Neon. Not declared here: a policy \`protected\` would\n// apply to every branch this policy is applied to.\n`
|
|
210
|
+
: "";
|
|
211
|
+
const body = [
|
|
212
|
+
...services,
|
|
213
|
+
...(preview === "" ? [] : [preview.trimEnd()]),
|
|
214
|
+
...(branch === "" ? [] : [branch.trimEnd()]),
|
|
215
|
+
].join("\n");
|
|
216
|
+
return {
|
|
217
|
+
source: `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
|
|
218
|
+
|
|
219
|
+
// Seeded by \`neon config init --from-branch\` from ${branchName}.
|
|
220
|
+
// The AI Gateway is not readable from a branch (always available, credential-gated), so add
|
|
221
|
+
// \`preview: { aiGateway: true }\` if the policy should declare it.
|
|
222
|
+
${protectedNote}export default defineConfig({
|
|
223
|
+
${body}
|
|
224
|
+
});
|
|
225
|
+
`,
|
|
226
|
+
seeded: true,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
107
229
|
/**
|
|
108
230
|
* The handler written alongside `neon.ts` when `functions` is selected. It has to exist:
|
|
109
231
|
* `FunctionDef.source` is only resolved when `config apply` / `deploy` bundles it, so a
|
package/dist/context.js
CHANGED
|
@@ -29,8 +29,17 @@ export const isCurrentBranchProbe = (args) => args.currentBranch === true &&
|
|
|
29
29
|
* never calls the Neon API. Gated on the exact command path so the global auth
|
|
30
30
|
* middleware and the single-project resolver can skip it (it runs with no API
|
|
31
31
|
* client), mirroring {@link isCurrentBranchProbe}.
|
|
32
|
+
*
|
|
33
|
+
* `--from-branch` is the exception: it seeds the policy from a branch's live state, so it
|
|
34
|
+
* needs both credentials and a resolved project. The raw argv is checked alongside the parsed
|
|
35
|
+
* flag because this runs from middleware that executes before validation, where the parsed
|
|
36
|
+
* value may not be populated yet (the same reason `analytics.ts` scans argv for
|
|
37
|
+
* `--current-branch`).
|
|
32
38
|
*/
|
|
33
|
-
export const isConfigInit = (args) => args._[0] === "config" &&
|
|
39
|
+
export const isConfigInit = (args) => args._[0] === "config" &&
|
|
40
|
+
args._[1] === "init" &&
|
|
41
|
+
args.fromBranch !== true &&
|
|
42
|
+
!process.argv.includes("--from-branch");
|
|
34
43
|
/**
|
|
35
44
|
* `neon profile …` manages credentials on disk and never calls the Neon API, so the global
|
|
36
45
|
* auth middleware must skip it — mirroring {@link isConfigInit}.
|
|
@@ -40,6 +49,11 @@ export const isConfigInit = (args) => args._[0] === "config" && args._[1] === "i
|
|
|
40
49
|
* access has already lapsed is the main reason to remove one.
|
|
41
50
|
*/
|
|
42
51
|
export const isProfileCommand = (args) => args._[0] === "profile" || args._[0] === "profiles";
|
|
52
|
+
/**
|
|
53
|
+
* `neon api-keys …`, under either spelling. Exempts the group from context enrichment: how
|
|
54
|
+
* far a credential reaches must come from an explicit flag, never from `.neon`.
|
|
55
|
+
*/
|
|
56
|
+
export const isApiKeysCommand = (args) => args._[0] === "api-keys" || args._[0] === "api-key";
|
|
43
57
|
const CONTEXT_FILE = ".neon";
|
|
44
58
|
const GITIGNORE_FILE = ".gitignore";
|
|
45
59
|
const canAccessFile = (file) => {
|
|
@@ -107,6 +121,13 @@ export const enrichFromContext = (args) => {
|
|
|
107
121
|
if (args._[0] === "link" || args._[0] === "set-context") {
|
|
108
122
|
return;
|
|
109
123
|
}
|
|
124
|
+
// `api-keys` mints credentials, and how far a credential reaches must be something the
|
|
125
|
+
// user typed — never something inherited from whichever project happens to be checked
|
|
126
|
+
// out. Enriched here, `api-keys create --name ci` in a linked directory would quietly
|
|
127
|
+
// produce a key scoped to that project instead of the account key it asked for.
|
|
128
|
+
if (isApiKeysCommand(args)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
110
131
|
const context = readContextFile(args.contextFile);
|
|
111
132
|
if (!args.orgId) {
|
|
112
133
|
args.orgId = context.orgId;
|
package/dist/utils/enrichers.js
CHANGED
|
@@ -42,9 +42,7 @@ export const branchIdFromProps = async (props) => {
|
|
|
42
42
|
return props.branchId;
|
|
43
43
|
};
|
|
44
44
|
export const resolveBranchRef = async (props) => {
|
|
45
|
-
const branch =
|
|
46
|
-
? props.branch
|
|
47
|
-
: props.id;
|
|
45
|
+
const branch = typeof props.branch === "string" ? props.branch : props.id;
|
|
48
46
|
const { data } = await props.apiClient.listProjectBranches({
|
|
49
47
|
projectId: props.projectId,
|
|
50
48
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.43.0",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -55,11 +55,11 @@
|
|
|
55
55
|
"which": "3.0.1",
|
|
56
56
|
"yaml": "^2.9.0",
|
|
57
57
|
"yargs": "17.7.2",
|
|
58
|
-
"@neon/
|
|
59
|
-
"@neon/
|
|
60
|
-
"@neon/config-runtime": "0.12.
|
|
61
|
-
"neon
|
|
62
|
-
"
|
|
58
|
+
"@neon/sdk": "1.5.0",
|
|
59
|
+
"@neon/config": "0.13.2",
|
|
60
|
+
"@neon/config-runtime": "0.12.3",
|
|
61
|
+
"@neon/env": "0.13.3",
|
|
62
|
+
"neon-init": "0.20.6"
|
|
63
63
|
},
|
|
64
64
|
"optionalDependencies": {
|
|
65
65
|
"esbuild": "0.28.1"
|