neon 2.39.1 → 2.42.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 +113 -5
- package/dist/analytics.js +2 -4
- package/dist/auth.js +29 -0
- package/dist/commands/auth.js +61 -14
- package/dist/commands/config.js +134 -55
- package/dist/commands/index.js +2 -0
- package/dist/commands/profile.js +123 -0
- package/dist/config.js +24 -6
- package/dist/config_template.js +242 -0
- package/dist/context.js +19 -1
- package/dist/index.js +5 -0
- package/dist/profiles.js +190 -0
- package/dist/utils/enrichers.js +1 -3
- package/dist/utils/service_picker.js +64 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -456,9 +456,55 @@ export default defineConfig({
|
|
|
456
456
|
});
|
|
457
457
|
```
|
|
458
458
|
|
|
459
|
-
|
|
459
|
+
### Getting a `neon.ts` (`config init`)
|
|
460
|
+
|
|
461
|
+
`neon config init` scaffolds the policy and installs `@neon/config` / `@neon/env`, so a project can go straight to `plan` / `apply`. It is purely local — no auth, no API calls. In an interactive terminal it asks which services the policy should declare:
|
|
462
|
+
|
|
463
|
+
```
|
|
464
|
+
? Which Neon services should neon.ts declare? (space to toggle, enter to confirm) ›
|
|
465
|
+
◯ Managed Better Auth
|
|
466
|
+
Authentication with users and sessions stored in Postgres.
|
|
467
|
+
◯ Functions
|
|
468
|
+
Long-running, without timeouts, and closer to your database.
|
|
469
|
+
◯ Object Storage
|
|
470
|
+
S3-compatible blob storage that branches with your projects.
|
|
471
|
+
◯ AI Gateway
|
|
472
|
+
All models, one API, one bill. Powered by Databricks. Not available on the Neon free plan.
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Selecting nothing is a valid answer: you get the starter policy, which is also what a non-interactive run (CI, no TTY) writes. Pass `--services` to skip the prompt anywhere:
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
# Pick interactively (TTY) or take the starter policy (CI)
|
|
479
|
+
neon config init
|
|
480
|
+
|
|
481
|
+
# Declare services with no prompt
|
|
482
|
+
neon config init --services auth,functions,storage,ai-gateway
|
|
483
|
+
|
|
484
|
+
# Explicitly ask for the bare starter policy
|
|
485
|
+
neon config init --services none
|
|
486
|
+
|
|
487
|
+
# Scaffold but print the install command instead of running it
|
|
488
|
+
neon config init --no-install
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Choosing **Functions** also writes the handler the policy points at, since `source` is only resolved when `apply` bundles it — a declared function with no file on disk fails at deploy:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
// hello.ts
|
|
495
|
+
export default async function hello(): Promise<Response> {
|
|
496
|
+
return new Response('Hello from Neon Functions');
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
An existing `neon.ts` (or `hello.ts`) is never overwritten.
|
|
501
|
+
|
|
502
|
+
Four sub-commands plus two top-level aliases drive it:
|
|
460
503
|
|
|
461
504
|
```bash
|
|
505
|
+
# Scaffold a neon.ts and install the config packages (local only)
|
|
506
|
+
neon config init
|
|
507
|
+
|
|
462
508
|
# Inspect the branch's live Neon state (read-only — never mutates)
|
|
463
509
|
neon config status
|
|
464
510
|
|
|
@@ -591,11 +637,57 @@ neon snapshots schedule set --branch main --schedule '[{"frequency":"weekly","da
|
|
|
591
637
|
|
|
592
638
|
All sub-commands honor the [global options](#global-options), including `--output json|yaml|table`.
|
|
593
639
|
|
|
640
|
+
## Profiles
|
|
641
|
+
|
|
642
|
+
The CLI holds one Neon account by default. A profile adds another, and is nothing more than a pointer to a credentials file:
|
|
643
|
+
|
|
644
|
+
```
|
|
645
|
+
~/.config/neon/
|
|
646
|
+
├── credentials.json # this IS the DEFAULT profile
|
|
647
|
+
├── credentials.work.json # created by `neon auth --profile work`
|
|
648
|
+
└── profiles.json # created only once a second profile exists
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
```bash
|
|
652
|
+
neon auth --profile work # create it, or sign in again
|
|
653
|
+
neon profile list
|
|
654
|
+
neon profile remove work
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
```console
|
|
658
|
+
$ neon profile list
|
|
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
|
+
```
|
|
668
|
+
|
|
669
|
+
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
|
+
|
|
671
|
+
Entries in `profiles.json` are paths, and a path may point anywhere — which is how you adopt a directory you already have, without moving or re-authenticating anything:
|
|
672
|
+
|
|
673
|
+
```json
|
|
674
|
+
{
|
|
675
|
+
"version": 1,
|
|
676
|
+
"profiles": {
|
|
677
|
+
"DEFAULT": { "credentials": "credentials.json" },
|
|
678
|
+
"work": { "credentials": "../neonctl-work/credentials.json" }
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
`neon profile 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 profile remove DEFAULT` signs you out.
|
|
684
|
+
|
|
594
685
|
## Commands
|
|
595
686
|
|
|
596
687
|
| Command | Subcommands | Description |
|
|
597
688
|
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
|
|
598
689
|
| [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
|
|
690
|
+
| profile | `list`, `remove` | Manage named sets of credentials |
|
|
599
691
|
| [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
|
|
600
692
|
| [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
|
|
601
693
|
| [me](https://neon.com/docs/reference/cli-me) | | Show current user |
|
|
@@ -612,7 +704,7 @@ All sub-commands honor the [global options](#global-options), including `--outpu
|
|
|
612
704
|
| checkout | | Pin a branch in `.neon` |
|
|
613
705
|
| diff | | Git-style schema diff vs a branch |
|
|
614
706
|
| [link](https://neon.com/docs/reference/cli-link) | | Link a directory to a project |
|
|
615
|
-
| config | `status`, `plan`, `apply`
|
|
707
|
+
| config | `init`, `status`, `plan`, `apply` | Drive a branch from `neon.ts` |
|
|
616
708
|
| deploy | | Alias for `config apply` |
|
|
617
709
|
| bootstrap | | Scaffold a project from a template |
|
|
618
710
|
| bucket | `create`, `list`, `delete`, `object list`, `object get`, `object put`, `object delete` (incl. `--recursive`) | Manage buckets and their objects |
|
|
@@ -625,7 +717,8 @@ Global options are supported with any Neon CLI command.
|
|
|
625
717
|
| Option | Description | Type | Default |
|
|
626
718
|
| :-------------------------- | :---------------------------------------------------------- | :------ | :----------------------------- |
|
|
627
719
|
| [-o, --output](#output) | Set the Neon CLI output format (`json`, `yaml`, or `table`) | string | table |
|
|
628
|
-
| [--config-dir](#config-dir) | Path to the Neon CLI configuration directory | string | `/home/<user>/.config/
|
|
720
|
+
| [--config-dir](#config-dir) | Path to the Neon CLI configuration directory | string | `/home/<user>/.config/neon` |
|
|
721
|
+
| [--profile](#profile) | Named credentials to use, from `profiles.json` | string | `DEFAULT` |
|
|
629
722
|
| [--api-key](#api-key) | Neon API key | string | "" |
|
|
630
723
|
| [--analytics](#analytics) | Manage analytics | boolean | true |
|
|
631
724
|
| [-v, --version](#version) | Show the Neon CLI version number | boolean | - |
|
|
@@ -641,12 +734,27 @@ Global options are supported with any Neon CLI command.
|
|
|
641
734
|
|
|
642
735
|
- <a id="config-dir"></a>`--config-dir`
|
|
643
736
|
|
|
644
|
-
Specifies the path to the `neon` configuration directory
|
|
737
|
+
Specifies the path to the `neon` configuration directory, which holds the `credentials.json` written by `neon auth`. The default is `$XDG_CONFIG_HOME/neon`, or `~/.config/neon`; run `neon --help` to see the resolved path. This option is only necessary if you keep your configuration somewhere else.
|
|
738
|
+
|
|
739
|
+
The directory was called `neonctl` before the CLI was renamed. An existing one is still read, and is used **in place** — nothing is moved or copied, so there is never a second credentials file to go stale. A directory you pass explicitly is used exactly as given and never falls back to the legacy name, so pointing a CI run at a scratch directory cannot pick up local credentials.
|
|
645
740
|
|
|
646
741
|
```bash
|
|
647
|
-
neon projects list --config-dir /home/dtprice/.config/
|
|
742
|
+
neon projects list --config-dir /home/dtprice/.config/neon
|
|
648
743
|
```
|
|
649
744
|
|
|
745
|
+
- <a id="profile"></a>`--profile`
|
|
746
|
+
|
|
747
|
+
Selects a named set of credentials, for holding more than one Neon account at a time. A profile is a pointer to a credentials file, recorded in `profiles.json` next to it.
|
|
748
|
+
|
|
749
|
+
```bash
|
|
750
|
+
neon auth --profile work # create it, or sign in again
|
|
751
|
+
neon profile list # names, accounts, and where each one's credentials live
|
|
752
|
+
neon projects list --profile work
|
|
753
|
+
NEON_PROFILE=work neon projects list
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Precedence is `--profile`, then `NEON_PROFILE`, then `DEFAULT`. `DEFAULT` is plain `credentials.json`, so an install with a single account needs no `profiles.json` and behaves exactly as before. See [Profiles](#profiles) to list or remove them.
|
|
757
|
+
|
|
650
758
|
- <a id="api-key"></a>`--api-key`
|
|
651
759
|
|
|
652
760
|
Specifies your Neon API key. You can authenticate using a Neon API key when running a Neon CLI command instead of using `neon auth`. For information about obtaining an Neon API key, see [Authentication](https://api-docs.neon.tech/reference/authentication), in the _Neon API Reference_.
|
package/dist/analytics.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
2
|
import { Analytics } from "@segment/analytics-node";
|
|
4
3
|
import { getApiClient, isNeonApiError } from "./api.js";
|
|
5
|
-
import {
|
|
4
|
+
import { credentialsPath } from "./config.js";
|
|
6
5
|
import { isCurrentBranchProbe } from "./context.js";
|
|
7
6
|
import { getGithubEnvVars, isCi } from "./env.js";
|
|
8
7
|
import { log } from "./log.js";
|
|
@@ -57,8 +56,7 @@ export const analyticsMiddleware = async (args) => {
|
|
|
57
56
|
return;
|
|
58
57
|
}
|
|
59
58
|
try {
|
|
60
|
-
const
|
|
61
|
-
const credentials = readFileSync(credentialsPath, {
|
|
59
|
+
const credentials = readFileSync(credentialsPath(args.configDir), {
|
|
62
60
|
encoding: "utf-8",
|
|
63
61
|
});
|
|
64
62
|
userId = JSON.parse(credentials).user_id;
|
package/dist/auth.js
CHANGED
|
@@ -38,6 +38,35 @@ export const refreshToken = async ({ oauthHost, clientId, allowUnsafeTls }, toke
|
|
|
38
38
|
});
|
|
39
39
|
return await client.refreshTokenGrant(configuration, tokenSet.refresh_token);
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Invalidate a refresh token at the authorization server (RFC 7009).
|
|
43
|
+
*
|
|
44
|
+
* Best-effort by design, and it returns a boolean rather than throwing: the usual reason to
|
|
45
|
+
* revoke is that a profile is being removed, and a revoke that fails — offline, token
|
|
46
|
+
* already dead, server unreachable — must not leave the local entry stranded. Deleting the
|
|
47
|
+
* file alone would only stop *us* using the token; this stops anyone.
|
|
48
|
+
*/
|
|
49
|
+
export const revokeToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) => {
|
|
50
|
+
const token = tokenSet.refresh_token;
|
|
51
|
+
if (typeof token !== "string" || token === "")
|
|
52
|
+
return false;
|
|
53
|
+
try {
|
|
54
|
+
const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: "none" }, client.None(), {
|
|
55
|
+
timeout: SERVER_TIMEOUT,
|
|
56
|
+
execute: allowUnsafeTls
|
|
57
|
+
? [client.allowInsecureRequests]
|
|
58
|
+
: undefined,
|
|
59
|
+
});
|
|
60
|
+
await client.tokenRevocation(configuration, token, {
|
|
61
|
+
token_type_hint: "refresh_token",
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
log.debug("Token revocation failed: %s", err instanceof Error ? err.message : String(err));
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
41
70
|
export const auth = async ({ oauthHost, clientId, allowUnsafeTls, }) => {
|
|
42
71
|
log.debug("Discovering oauth server");
|
|
43
72
|
const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: "none" }, client.None(), {
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
3
|
import { getApiClient } from "../api.js";
|
|
5
4
|
import { auth, refreshToken } from "../auth.js";
|
|
6
5
|
import { setAuthContext } from "../auth_context.js";
|
|
7
|
-
import {
|
|
8
|
-
import { isConfigInit, isCurrentBranchProbe } from "../context.js";
|
|
6
|
+
import { credentialsPath as defaultCredentialsPath } from "../config.js";
|
|
7
|
+
import { isConfigInit, isCurrentBranchProbe, isProfileCommand, } from "../context.js";
|
|
9
8
|
import { isCi } from "../env.js";
|
|
10
9
|
import { log } from "../log.js";
|
|
10
|
+
import { assertValidProfileName, DEFAULT_PROFILE, newProfileCredentialsPath, readProfiles, resolveProfile, selectProfileName, upsertProfile, } from "../profiles.js";
|
|
11
11
|
import { extendTokenSet } from "../utils/auth.js";
|
|
12
|
+
/**
|
|
13
|
+
* The credentials file this invocation reads and writes: the selected profile's, falling
|
|
14
|
+
* back to plain `credentials.json` when no profile was named and none is declared. An
|
|
15
|
+
* unknown profile name is a hard error rather than a silent write to the default file —
|
|
16
|
+
* a typo must not authenticate the wrong account.
|
|
17
|
+
*/
|
|
18
|
+
const credentialsPathFor = ({ configDir, profile, }) => {
|
|
19
|
+
const name = selectProfileName(profile);
|
|
20
|
+
if (name === DEFAULT_PROFILE && !readProfiles(configDir))
|
|
21
|
+
return defaultCredentialsPath(configDir);
|
|
22
|
+
return resolveProfile(configDir, name).credentialsPath;
|
|
23
|
+
};
|
|
12
24
|
export const command = "auth";
|
|
13
25
|
export const aliases = ["login"];
|
|
14
26
|
export const describe = "Authenticate";
|
|
@@ -18,7 +30,7 @@ export const builder = (yargs) => yargs.option("context-file", {
|
|
|
18
30
|
export const handler = async (args) => {
|
|
19
31
|
await authFlow(args);
|
|
20
32
|
};
|
|
21
|
-
export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, "force-auth": forceAuthKebab, allowUnsafeTls, }) => {
|
|
33
|
+
export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, "force-auth": forceAuthKebab, allowUnsafeTls, profile, }) => {
|
|
22
34
|
const allowInteractiveAuth = forceAuth ?? forceAuthKebab;
|
|
23
35
|
if (!allowInteractiveAuth && isCi()) {
|
|
24
36
|
throw new Error("Cannot run interactive auth in CI");
|
|
@@ -28,9 +40,19 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
|
|
|
28
40
|
clientId: clientId,
|
|
29
41
|
allowUnsafeTls,
|
|
30
42
|
});
|
|
31
|
-
|
|
43
|
+
// A named profile that doesn't exist yet is created here rather than erroring: `neon
|
|
44
|
+
// auth --profile work` is how you make one, so it must work before there is anything
|
|
45
|
+
// to look up.
|
|
46
|
+
const profileName = selectProfileName(profile);
|
|
47
|
+
const isNamed = profileName !== DEFAULT_PROFILE;
|
|
48
|
+
if (isNamed)
|
|
49
|
+
assertValidProfileName(profileName);
|
|
50
|
+
const credentialsPath = isNamed && !readProfiles(configDir)?.profiles[profileName]
|
|
51
|
+
? newProfileCredentialsPath(configDir, profileName)
|
|
52
|
+
: credentialsPathFor({ configDir, profile });
|
|
53
|
+
let identity = {};
|
|
32
54
|
try {
|
|
33
|
-
await preserveCredentials(credentialsPath, tokenSet, getApiClient({
|
|
55
|
+
identity = await preserveCredentials(credentialsPath, tokenSet, getApiClient({
|
|
34
56
|
apiKey: tokenSet.access_token || "",
|
|
35
57
|
apiHost,
|
|
36
58
|
}));
|
|
@@ -39,22 +61,36 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
|
|
|
39
61
|
log.error("Failed to save credentials");
|
|
40
62
|
return "";
|
|
41
63
|
}
|
|
64
|
+
if (isNamed) {
|
|
65
|
+
upsertProfile(configDir, profileName, {
|
|
66
|
+
credentials: credentialsPath,
|
|
67
|
+
...(identity.email ? { label: identity.email } : {}),
|
|
68
|
+
...(identity.id ? { userId: identity.id } : {}),
|
|
69
|
+
});
|
|
70
|
+
log.info('Saved profile "%s" (%s)', profileName, credentialsPath);
|
|
71
|
+
}
|
|
42
72
|
log.info("Auth complete");
|
|
43
73
|
return tokenSet.access_token || "";
|
|
44
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* Persist the token set and return the account it belongs to, so a named profile can be
|
|
77
|
+
* labelled with an email. The credentials file records only `user_id` — a UUID with no
|
|
78
|
+
* email — which is why identifying a stored profile offline is otherwise impossible.
|
|
79
|
+
*/
|
|
45
80
|
const preserveCredentials = async (path, credentials, apiClient) => {
|
|
46
|
-
const { data: { id }, } = await apiClient.getCurrentUserInfo();
|
|
81
|
+
const { data: { id, email }, } = await apiClient.getCurrentUserInfo();
|
|
47
82
|
const contents = JSON.stringify({
|
|
48
83
|
// Cast to a plain record: we intentionally spread the credentials object.
|
|
49
84
|
...credentials,
|
|
50
85
|
user_id: id,
|
|
51
86
|
});
|
|
52
|
-
//
|
|
87
|
+
// Owner-only. A credentials file needs read/write, never execute.
|
|
53
88
|
writeFileSync(path, contents, {
|
|
54
|
-
mode:
|
|
89
|
+
mode: 0o600,
|
|
55
90
|
});
|
|
56
91
|
log.debug("Saved credentials to %s", path);
|
|
57
92
|
log.debug("Credentials MD5 hash: %s", md5hash(contents));
|
|
93
|
+
return { ...(id ? { id } : {}), ...(email ? { email } : {}) };
|
|
58
94
|
};
|
|
59
95
|
const handleExistingToken = async (tokenSet, props, credentialsPath) => {
|
|
60
96
|
// Use existing access_token, if present and valid
|
|
@@ -113,6 +149,11 @@ export const ensureAuth = async (props) => {
|
|
|
113
149
|
if (isConfigInit(props)) {
|
|
114
150
|
return;
|
|
115
151
|
}
|
|
152
|
+
// `profile` reads and edits credential files on disk. Authenticating first would mean
|
|
153
|
+
// a browser login just to list profiles, and would make a lapsed profile unremovable.
|
|
154
|
+
if (isProfileCommand(props)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
116
157
|
// `dev` runs a function locally. It injects the selected branch's env vars
|
|
117
158
|
// when credentials happen to be available, but must never trigger an
|
|
118
159
|
// interactive login: use an API key or existing stored credentials if
|
|
@@ -141,7 +182,7 @@ export const ensureAuth = async (props) => {
|
|
|
141
182
|
});
|
|
142
183
|
return;
|
|
143
184
|
}
|
|
144
|
-
const credentialsPath =
|
|
185
|
+
const credentialsPath = credentialsPathFor(props);
|
|
145
186
|
// Handle case when credentials file exists
|
|
146
187
|
if (existsSync(credentialsPath)) {
|
|
147
188
|
log.debug("Trying to read credentials from %s", credentialsPath);
|
|
@@ -204,11 +245,17 @@ export const ensureAuth = async (props) => {
|
|
|
204
245
|
});
|
|
205
246
|
};
|
|
206
247
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
248
|
+
* Delete the credentials backing a profile — used by the 401 handler to clear a token the
|
|
249
|
+
* API has rejected, so the next command re-authenticates instead of failing again.
|
|
250
|
+
*
|
|
251
|
+
* @param configDir Directory the credentials live in
|
|
252
|
+
* @param profile Profile whose credentials to clear. Defaults to the selected one.
|
|
209
253
|
*/
|
|
210
|
-
export const deleteCredentials = (configDir) => {
|
|
211
|
-
const credentialsPath =
|
|
254
|
+
export const deleteCredentials = (configDir, profile) => {
|
|
255
|
+
const credentialsPath = credentialsPathFor({
|
|
256
|
+
configDir,
|
|
257
|
+
...(profile ? { profile } : {}),
|
|
258
|
+
});
|
|
212
259
|
try {
|
|
213
260
|
if (existsSync(credentialsPath)) {
|
|
214
261
|
rmSync(credentialsPath);
|
package/dist/commands/config.js
CHANGED
|
@@ -5,6 +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, renderNeonConfigFromView, } from "../config_template.js";
|
|
8
9
|
import { contextBranch, readContextFile } from "../context.js";
|
|
9
10
|
import { isCi } from "../env.js";
|
|
10
11
|
import { loadEnvFileIntoProcess } from "../env_file.js";
|
|
@@ -15,6 +16,7 @@ import { renderAppliedChanges, renderBranchSettingConflicts, } from "../utils/co
|
|
|
15
16
|
import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
|
|
16
17
|
import { bundleEntry } from "../utils/esbuild.js";
|
|
17
18
|
import { addDependenciesArgs, resolvePackageManager, runCommand, } from "../utils/package_manager.js";
|
|
19
|
+
import { pickServicesInteractively } from "../utils/service_picker.js";
|
|
18
20
|
import { zipBundle } from "../utils/zip.js";
|
|
19
21
|
import { writer } from "../writer.js";
|
|
20
22
|
import { autoPullEnvAfterPin } from "./env.js";
|
|
@@ -69,18 +71,6 @@ export const envPullFlag = {
|
|
|
69
71
|
},
|
|
70
72
|
};
|
|
71
73
|
// ── `config init` ─────────────────────────────────────────────────────────────
|
|
72
|
-
/**
|
|
73
|
-
* The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
|
|
74
|
-
*
|
|
75
|
-
* ⚠️ These ship to users the next time `neonctl` is released, so do NOT release
|
|
76
|
-
* neonctl until `@neon/config` and `@neon/env` are published to npm — otherwise
|
|
77
|
-
* `config init` would install packages that don't exist yet. (The libraries are
|
|
78
|
-
* mid-migration from `@neondatabase/*`; track their publish before cutting a CLI
|
|
79
|
-
* release.)
|
|
80
|
-
*/
|
|
81
|
-
const CONFIG_PACKAGE = "@neon/config";
|
|
82
|
-
const ENV_PACKAGE = "@neon/env";
|
|
83
|
-
const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
|
|
84
74
|
/** package.json fields a dependency can be declared in. */
|
|
85
75
|
const DEPENDENCY_FIELDS = [
|
|
86
76
|
"dependencies",
|
|
@@ -92,28 +82,6 @@ const DEPENDENCY_FIELDS = [
|
|
|
92
82
|
const NEON_CONFIG_FILENAMES = ["neon.ts", "neon.mts", "neon.js", "neon.mjs"];
|
|
93
83
|
/** Whether `dir` already has a Neon config file the runtime would load. */
|
|
94
84
|
export const hasNeonConfigFile = (dir) => NEON_CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
|
|
95
|
-
/** Starter `neon.ts` written by `config init` when a project has none. */
|
|
96
|
-
const NEON_CONFIG_TEMPLATE = `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
|
|
97
|
-
|
|
98
|
-
export default defineConfig({
|
|
99
|
-
// Declare your Neon services here
|
|
100
|
-
auth: false,
|
|
101
|
-
// Branch policy: per-branch tuning
|
|
102
|
-
branch: (branch) => {
|
|
103
|
-
if (branch.isDefault) {
|
|
104
|
-
// Default branch: no overrides, uses project defaults
|
|
105
|
-
return {};
|
|
106
|
-
}
|
|
107
|
-
if (!branch.exists) {
|
|
108
|
-
// New non-default branches: auto-expire
|
|
109
|
-
// Run \`neon checkout <name>\` to create a new branch with these settings
|
|
110
|
-
return { ttl: "7d" };
|
|
111
|
-
}
|
|
112
|
-
// Existing branch: no changes
|
|
113
|
-
return {};
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
`;
|
|
117
85
|
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
118
86
|
/**
|
|
119
87
|
* The {@link REQUIRED_PACKAGES} not already declared in the project's package.json
|
|
@@ -143,22 +111,104 @@ const missingDependencies = (cwd) => {
|
|
|
143
111
|
}
|
|
144
112
|
return REQUIRED_PACKAGES.filter((pkg) => !declared.has(pkg));
|
|
145
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* Which services the scaffolded policy declares. `--services` always wins so a script or an
|
|
116
|
+
* agent gets the same result without a TTY; an injected picker is next (tests); the real
|
|
117
|
+
* picker only runs on an interactive terminal outside CI. Everything else scaffolds the
|
|
118
|
+
* starter policy, which is what `config init` has always written.
|
|
119
|
+
*/
|
|
120
|
+
const resolveServices = async (props) => {
|
|
121
|
+
if (props.services !== undefined) {
|
|
122
|
+
return parseServices(props.services);
|
|
123
|
+
}
|
|
124
|
+
if (props.pickServices) {
|
|
125
|
+
return props.pickServices();
|
|
126
|
+
}
|
|
127
|
+
if (isCi() || !process.stdout.isTTY) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
return pickServicesInteractively();
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Write the hello-world handler the scaffolded `preview.functions` entry points at. An
|
|
134
|
+
* existing `hello.ts` is left alone: the declared function keeps pointing at it, which is the
|
|
135
|
+
* better outcome than overwriting a file the user wrote.
|
|
136
|
+
*/
|
|
137
|
+
const scaffoldFunction = (cwd) => {
|
|
138
|
+
const path = join(cwd, FUNCTION_FILENAME);
|
|
139
|
+
if (existsSync(path)) {
|
|
140
|
+
log.info("Found an existing %s — leaving it untouched; the %s function points at it.", FUNCTION_FILENAME, FUNCTION_SLUG);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(path, FUNCTION_TEMPLATE);
|
|
144
|
+
log.info("Created %s — the source of the %s function.", FUNCTION_FILENAME, FUNCTION_SLUG);
|
|
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();
|
|
153
182
|
const run = props.run ?? runCommand;
|
|
154
|
-
// 1. Scaffold neon.ts unless the project already has a Neon config file.
|
|
183
|
+
// 1. Scaffold neon.ts unless the project already has a Neon config file. Resolving the
|
|
184
|
+
// services (which may prompt) happens only when there is something to write — asking
|
|
185
|
+
// which services to declare and then declaring nothing would be a lie.
|
|
155
186
|
const existing = NEON_CONFIG_FILENAMES.find((name) => existsSync(join(cwd, name)));
|
|
156
187
|
if (existing) {
|
|
157
188
|
log.info("Found an existing %s — leaving it untouched.", existing);
|
|
158
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
|
+
}
|
|
159
200
|
else {
|
|
160
|
-
|
|
161
|
-
|
|
201
|
+
const services = await resolveServices(props);
|
|
202
|
+
writeFileSync(join(cwd, "neon.ts"), renderNeonConfig(services));
|
|
203
|
+
if (services.length === 0) {
|
|
204
|
+
log.info("Created neon.ts with a starter policy.");
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
log.info("Created neon.ts declaring %s.", services.join(", "));
|
|
208
|
+
}
|
|
209
|
+
if (services.includes("functions")) {
|
|
210
|
+
scaffoldFunction(cwd);
|
|
211
|
+
}
|
|
162
212
|
}
|
|
163
213
|
// 2. Make sure the config packages are installed.
|
|
164
214
|
const missing = missingDependencies(cwd);
|
|
@@ -234,10 +284,53 @@ export const builder = (argv) => argv
|
|
|
234
284
|
type: "boolean",
|
|
235
285
|
default: true,
|
|
236
286
|
},
|
|
287
|
+
services: {
|
|
288
|
+
describe: `Services the scaffolded neon.ts declares, comma-separated: ${NEON_SERVICES.join(", ")}. ` +
|
|
289
|
+
`Pass "${NO_SERVICES}" for the bare starter policy. Omitted: pick interactively on a ` +
|
|
290
|
+
"terminal, starter policy in CI or without a TTY.",
|
|
291
|
+
type: "string",
|
|
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
|
+
},
|
|
237
302
|
}), (args) => initCmd(args));
|
|
238
303
|
export const handler = (args) => {
|
|
239
304
|
return args;
|
|
240
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
|
+
};
|
|
241
334
|
const loadConfig = async (props) => {
|
|
242
335
|
// Load the optional --env file FIRST so a `neon.ts` whose function `env` values read
|
|
243
336
|
// `process.env.X` sees them. Must happen before the policy module is imported/evaluated.
|
|
@@ -272,27 +365,13 @@ export const status = async (props) => {
|
|
|
272
365
|
if (!props.configJson) {
|
|
273
366
|
announceTargetBranch(props, branch, "Inspecting branch");
|
|
274
367
|
}
|
|
275
|
-
const
|
|
276
|
-
const live = await inspect({
|
|
368
|
+
const { live, view: configView } = await liveConfigView({
|
|
277
369
|
projectId: props.projectId,
|
|
278
|
-
branchId,
|
|
370
|
+
branchId: branch.branchId,
|
|
279
371
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
280
372
|
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
281
|
-
...(props.runtimeApi ? {
|
|
282
|
-
});
|
|
283
|
-
// The pulled `config` carries the branch's tuning inside a closure that JSON can't
|
|
284
|
-
// render. Resolve it against the live branch target to get the concrete settings, then
|
|
285
|
-
// project both that and the separately-pulled preview state into a neon.ts-shaped view.
|
|
286
|
-
const resolved = resolveConfig(live.config, {
|
|
287
|
-
name: live.branch.name,
|
|
288
|
-
id: live.branch.id,
|
|
289
|
-
exists: true,
|
|
290
|
-
isDefault: live.branch.isDefault,
|
|
291
|
-
isProtected: live.branch.protected,
|
|
292
|
-
...(live.branch.parent ? { parentId: live.branch.parent } : {}),
|
|
293
|
-
...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
|
|
373
|
+
...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
|
|
294
374
|
});
|
|
295
|
-
const configView = toNeonConfigView(resolved, live.preview);
|
|
296
375
|
// `--config-json`: emit just the neon.ts-shaped config to stdout (script-friendly,
|
|
297
376
|
// copy-paste-able), regardless of the global --output.
|
|
298
377
|
if (props.configJson) {
|
package/dist/commands/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import * as link from "./link.js";
|
|
|
20
20
|
import * as neonAuth from "./neon_auth.js";
|
|
21
21
|
import * as operations from "./operations.js";
|
|
22
22
|
import * as orgs from "./orgs.js";
|
|
23
|
+
import * as profile from "./profile.js";
|
|
23
24
|
import * as projects from "./projects.js";
|
|
24
25
|
import * as psql from "./psql.js";
|
|
25
26
|
import * as roles from "./roles.js";
|
|
@@ -30,6 +31,7 @@ import * as users from "./user.js";
|
|
|
30
31
|
import * as vpcEndpoints from "./vpc_endpoints.js";
|
|
31
32
|
export default [
|
|
32
33
|
auth,
|
|
34
|
+
profile,
|
|
33
35
|
api,
|
|
34
36
|
users,
|
|
35
37
|
orgs,
|