neon 2.39.1 → 2.41.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 +54 -37
- 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 +120 -0
- package/dist/context.js +9 -0
- package/dist/index.js +5 -0
- package/dist/profiles.js +190 -0
- package/dist/utils/service_picker.js +64 -0
- package/package.json +5 -5
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, } 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,6 +111,38 @@ 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
146
|
/**
|
|
147
147
|
* Scaffold a `neon.ts` policy and make sure the Neon config packages are
|
|
148
148
|
* installed, so a project can go straight to `neon config plan` / `apply`.
|
|
@@ -151,14 +151,25 @@ const missingDependencies = (cwd) => {
|
|
|
151
151
|
export const initCmd = async (props) => {
|
|
152
152
|
const cwd = props.cwd ?? process.cwd();
|
|
153
153
|
const run = props.run ?? runCommand;
|
|
154
|
-
// 1. Scaffold neon.ts unless the project already has a Neon config file.
|
|
154
|
+
// 1. Scaffold neon.ts unless the project already has a Neon config file. Resolving the
|
|
155
|
+
// services (which may prompt) happens only when there is something to write — asking
|
|
156
|
+
// which services to declare and then declaring nothing would be a lie.
|
|
155
157
|
const existing = NEON_CONFIG_FILENAMES.find((name) => existsSync(join(cwd, name)));
|
|
156
158
|
if (existing) {
|
|
157
159
|
log.info("Found an existing %s — leaving it untouched.", existing);
|
|
158
160
|
}
|
|
159
161
|
else {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
const services = await resolveServices(props);
|
|
163
|
+
writeFileSync(join(cwd, "neon.ts"), renderNeonConfig(services));
|
|
164
|
+
if (services.length === 0) {
|
|
165
|
+
log.info("Created neon.ts with a starter policy.");
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
log.info("Created neon.ts declaring %s.", services.join(", "));
|
|
169
|
+
}
|
|
170
|
+
if (services.includes("functions")) {
|
|
171
|
+
scaffoldFunction(cwd);
|
|
172
|
+
}
|
|
162
173
|
}
|
|
163
174
|
// 2. Make sure the config packages are installed.
|
|
164
175
|
const missing = missingDependencies(cwd);
|
|
@@ -234,6 +245,12 @@ export const builder = (argv) => argv
|
|
|
234
245
|
type: "boolean",
|
|
235
246
|
default: true,
|
|
236
247
|
},
|
|
248
|
+
services: {
|
|
249
|
+
describe: `Services the scaffolded neon.ts declares, comma-separated: ${NEON_SERVICES.join(", ")}. ` +
|
|
250
|
+
`Pass "${NO_SERVICES}" for the bare starter policy. Omitted: pick interactively on a ` +
|
|
251
|
+
"terminal, starter policy in CI or without a TTY.",
|
|
252
|
+
type: "string",
|
|
253
|
+
},
|
|
237
254
|
}), (args) => initCmd(args));
|
|
238
255
|
export const handler = (args) => {
|
|
239
256
|
return args;
|
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,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import { revokeToken } from "../auth.js";
|
|
5
|
+
import { isCi } from "../env.js";
|
|
6
|
+
import { log } from "../log.js";
|
|
7
|
+
import { DEFAULT_PROFILE, listProfiles, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, } from "../profiles.js";
|
|
8
|
+
import { writer } from "../writer.js";
|
|
9
|
+
export const command = "profile";
|
|
10
|
+
export const aliases = ["profiles"];
|
|
11
|
+
export const describe = "Manage named sets of Neon credentials";
|
|
12
|
+
export const builder = (argv) => argv
|
|
13
|
+
.usage("$0 profile <sub-command> [options]")
|
|
14
|
+
.command("list", "List profiles, the account each holds, and where its credentials live", (y) => y, async (args) => await list(args))
|
|
15
|
+
.command("remove <name>", "Revoke a profile's token and remove it", (y) => y
|
|
16
|
+
.positional("name", {
|
|
17
|
+
describe: "Profile to remove",
|
|
18
|
+
type: "string",
|
|
19
|
+
demandOption: true,
|
|
20
|
+
})
|
|
21
|
+
.option("yes", {
|
|
22
|
+
alias: "y",
|
|
23
|
+
describe: "Skip the confirmation prompt",
|
|
24
|
+
type: "boolean",
|
|
25
|
+
default: false,
|
|
26
|
+
}), async (args) => await remove(args))
|
|
27
|
+
.demandCommand(1, "Run `neon profile --help` to see the subcommands.");
|
|
28
|
+
export const handler = (_args) => {
|
|
29
|
+
/* subcommands only */
|
|
30
|
+
};
|
|
31
|
+
const list = async (props) => {
|
|
32
|
+
const active = selectProfileName(props.profile);
|
|
33
|
+
const rows = listProfiles(props.configDir).map((p) => ({
|
|
34
|
+
active: p.name === active ? "*" : "",
|
|
35
|
+
name: p.name,
|
|
36
|
+
account: p.label ?? p.userId ?? "-",
|
|
37
|
+
credentials: p.credentialsPath,
|
|
38
|
+
signedIn: existsSync(p.credentialsPath) ? "yes" : "no",
|
|
39
|
+
}));
|
|
40
|
+
writer(props).end(rows, {
|
|
41
|
+
title: "Profiles",
|
|
42
|
+
fields: ["active", "name", "account", "signedIn", "credentials"],
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const remove = async (props) => {
|
|
46
|
+
const { name } = props;
|
|
47
|
+
// Resolve before touching anything: an unknown name must fail having deleted nothing.
|
|
48
|
+
const profile = resolveProfile(props.configDir, name);
|
|
49
|
+
if (!props.yes) {
|
|
50
|
+
if (isCi()) {
|
|
51
|
+
throw new Error("Refusing to remove a profile without confirmation in CI. Pass --yes.");
|
|
52
|
+
}
|
|
53
|
+
const who = profile.label ?? profile.userId ?? "unknown account";
|
|
54
|
+
const { ok } = await prompts({
|
|
55
|
+
type: "confirm",
|
|
56
|
+
name: "ok",
|
|
57
|
+
message: `Remove profile "${name}" (${who})?`,
|
|
58
|
+
initial: false,
|
|
59
|
+
});
|
|
60
|
+
if (!ok) {
|
|
61
|
+
log.info("Cancelled.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 1. Revoke upstream, so the token dies rather than merely becoming unreachable by us.
|
|
66
|
+
// Best-effort: a profile is often removed precisely because its access already broke.
|
|
67
|
+
const revoked = await revokeStoredToken(profile.credentialsPath, props);
|
|
68
|
+
log.info(revoked
|
|
69
|
+
? "Revoked the OAuth token"
|
|
70
|
+
: "Could not revoke the OAuth token — removing locally anyway");
|
|
71
|
+
// 2. Delete the credentials file only if we created it. A profile pointing outside the
|
|
72
|
+
// config directory was adopted from elsewhere; unlink it and say so, because the
|
|
73
|
+
// secret is still on disk and silence would imply otherwise.
|
|
74
|
+
if (existsSync(profile.credentialsPath)) {
|
|
75
|
+
if (isInsideConfigDir(props.configDir, profile.credentialsPath)) {
|
|
76
|
+
rmSync(profile.credentialsPath);
|
|
77
|
+
log.info("Deleted %s", profile.credentialsPath);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
log.info("Left %s on disk — not created by neon", profile.credentialsPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 3. Drop the entry, and the file once nothing but DEFAULT is left — the mirror image
|
|
84
|
+
// of creating it lazily, so a single-account install ends up with no profiles.json.
|
|
85
|
+
const path = profilesFilePath(props.configDir);
|
|
86
|
+
const file = readProfiles(props.configDir);
|
|
87
|
+
if (file?.profiles[name]) {
|
|
88
|
+
delete file.profiles[name];
|
|
89
|
+
if (onlyDefaultRemains(file)) {
|
|
90
|
+
rmSync(path);
|
|
91
|
+
log.info('Removed "%s" — no profiles left, deleted %s', name, path);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, {
|
|
95
|
+
mode: 0o600,
|
|
96
|
+
});
|
|
97
|
+
log.info('Removed "%s" from %s', name, path);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (name === DEFAULT_PROFILE) {
|
|
101
|
+
log.info("Signed out of DEFAULT");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const revokeStoredToken = async (credentialsPath, props) => {
|
|
105
|
+
if (!existsSync(credentialsPath))
|
|
106
|
+
return false;
|
|
107
|
+
let tokenSet;
|
|
108
|
+
try {
|
|
109
|
+
tokenSet = JSON.parse(readFileSync(credentialsPath, "utf8"));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return await revokeToken({
|
|
115
|
+
oauthHost: props.oauthHost,
|
|
116
|
+
clientId: props.clientId,
|
|
117
|
+
...(props.allowUnsafeTls
|
|
118
|
+
? { allowUnsafeTls: props.allowUnsafeTls }
|
|
119
|
+
: {}),
|
|
120
|
+
}, tokenSet);
|
|
121
|
+
};
|
|
122
|
+
/** Whether a credentials file is one the CLI created, rather than an adopted path. */
|
|
123
|
+
const isInsideConfigDir = (configDir, file) => `${resolve(file)}/`.startsWith(`${resolve(configDir)}/`);
|
package/dist/config.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { configDir, resolveConfigFile } from "@neon/config/paths";
|
|
4
3
|
import { isCi } from "./env.js";
|
|
5
4
|
export const CREDENTIALS_FILE = "credentials.json";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
|
|
7
|
+
*
|
|
8
|
+
* The directory was called `neonctl` until the CLI was renamed. An existing one is still
|
|
9
|
+
* read — see {@link credentialsPath} — but it is never written to, moved, or deleted.
|
|
10
|
+
*/
|
|
11
|
+
export const defaultDir = configDir();
|
|
12
|
+
/**
|
|
13
|
+
* Where this invocation's `credentials.json` lives.
|
|
14
|
+
*
|
|
15
|
+
* When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
|
|
16
|
+
* directory is used **in place**: an install that predates the rename keeps working, and
|
|
17
|
+
* its credentials are never duplicated into a second location where one copy could go
|
|
18
|
+
* stale while another tool still reads it.
|
|
19
|
+
*
|
|
20
|
+
* A `--config-dir` the user actually passed is used exactly as given. Falling back out of
|
|
21
|
+
* an explicitly chosen directory would defeat the reason for choosing it — a CI run
|
|
22
|
+
* pointed at a scratch directory must never pick up a developer's real credentials.
|
|
23
|
+
*/
|
|
24
|
+
export const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
25
|
+
export const ensureConfigDir = ({ "config-dir": configDirArg, "force-auth": forceAuth, }) => {
|
|
26
|
+
if (!existsSync(configDirArg) && (!isCi() || forceAuth)) {
|
|
27
|
+
mkdirSync(configDirArg, { recursive: true });
|
|
10
28
|
}
|
|
11
29
|
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ These ship to users the next time `neonctl` is released, so do NOT release
|
|
5
|
+
* neonctl until `@neon/config` and `@neon/env` are published to npm — otherwise
|
|
6
|
+
* `config init` would install packages that don't exist yet. (The libraries are
|
|
7
|
+
* mid-migration from `@neondatabase/*`; track their publish before cutting a CLI
|
|
8
|
+
* release.)
|
|
9
|
+
*/
|
|
10
|
+
export const CONFIG_PACKAGE = "@neon/config";
|
|
11
|
+
export const ENV_PACKAGE = "@neon/env";
|
|
12
|
+
export const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
|
|
13
|
+
/**
|
|
14
|
+
* A Neon service `config init` can declare in the `neon.ts` it scaffolds, spelled the way a
|
|
15
|
+
* user types it in `--services`. Kebab-case rather than the `neon.ts` field names (`aiGateway`,
|
|
16
|
+
* `buckets`) so the flag reads like a flag; {@link renderNeonConfig} owns the mapping.
|
|
17
|
+
*
|
|
18
|
+
* Postgres is absent because every branch has it, and `dataApi` is absent because enabling it
|
|
19
|
+
* with the default `authProvider: "neon"` requires `auth` — a pairing the picker would have to
|
|
20
|
+
* enforce rather than offer.
|
|
21
|
+
*/
|
|
22
|
+
export const NEON_SERVICES = [
|
|
23
|
+
"auth",
|
|
24
|
+
"functions",
|
|
25
|
+
"storage",
|
|
26
|
+
"ai-gateway",
|
|
27
|
+
];
|
|
28
|
+
/** `--services none`: declare nothing, i.e. scaffold the bare starter policy. */
|
|
29
|
+
export const NO_SERVICES = "none";
|
|
30
|
+
/** Slug, display name, and source path of the function scaffolded for `functions`. */
|
|
31
|
+
export const FUNCTION_SLUG = "hello";
|
|
32
|
+
export const FUNCTION_NAME = "Hello World";
|
|
33
|
+
export const FUNCTION_FILENAME = "hello.ts";
|
|
34
|
+
/** Name of the bucket scaffolded for `storage`. */
|
|
35
|
+
export const BUCKET_NAME = "assets";
|
|
36
|
+
/**
|
|
37
|
+
* Parse a `--services` value into a canonical service list: comma-separated
|
|
38
|
+
* {@link NEON_SERVICES} names, or {@link NO_SERVICES} on its own for none.
|
|
39
|
+
*
|
|
40
|
+
* Unknown names are rejected here rather than silently dropped — a typo'd service would
|
|
41
|
+
* otherwise scaffold a policy missing exactly the service the user asked for. The result is
|
|
42
|
+
* deduplicated and ordered by {@link NEON_SERVICES} so the rendered file doesn't depend on the
|
|
43
|
+
* order they were typed in.
|
|
44
|
+
*/
|
|
45
|
+
export const parseServices = (raw) => {
|
|
46
|
+
const names = raw
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((name) => name.trim())
|
|
49
|
+
.filter((name) => name !== "");
|
|
50
|
+
if (names.includes(NO_SERVICES)) {
|
|
51
|
+
if (names.length > 1) {
|
|
52
|
+
throw new Error(`--services ${NO_SERVICES} cannot be combined with other services.`);
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const unknown = names.filter((name) => !NEON_SERVICES.includes(name));
|
|
57
|
+
if (unknown.length > 0) {
|
|
58
|
+
throw new Error(`Unknown service${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}. ` +
|
|
59
|
+
`Supported values: ${NEON_SERVICES.join(", ")}, ${NO_SERVICES}.`);
|
|
60
|
+
}
|
|
61
|
+
return NEON_SERVICES.filter((service) => names.includes(service));
|
|
62
|
+
};
|
|
63
|
+
/** The `preview` block for the selected services, or "" when none of them is a preview feature. */
|
|
64
|
+
const renderPreview = (services) => {
|
|
65
|
+
const lines = [];
|
|
66
|
+
if (services.includes("ai-gateway")) {
|
|
67
|
+
lines.push(" aiGateway: true,");
|
|
68
|
+
}
|
|
69
|
+
if (services.includes("functions")) {
|
|
70
|
+
lines.push(" functions: {", ` ${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`, " },");
|
|
71
|
+
}
|
|
72
|
+
if (services.includes("storage")) {
|
|
73
|
+
lines.push(" buckets: {", ` // "private" is the default; use "public_read" for anonymous reads`, ` ${BUCKET_NAME}: { access: "private" },`, " },");
|
|
74
|
+
}
|
|
75
|
+
if (lines.length === 0) {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
return `${[" preview: {", ...lines, " },"].join("\n")}\n`;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Render the `neon.ts` policy `config init` writes. With no services this is the starter
|
|
82
|
+
* policy — an explicit `auth: false`, no `preview` block, and a `branch` closure that gives
|
|
83
|
+
* new non-default branches a 7-day TTL — so the picker's "skip everything" answer and a
|
|
84
|
+
* non-interactive run produce the identical file.
|
|
85
|
+
*/
|
|
86
|
+
export const renderNeonConfig = (services) => `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
|
|
87
|
+
|
|
88
|
+
export default defineConfig({
|
|
89
|
+
// Declare your Neon services here
|
|
90
|
+
auth: ${services.includes("auth")},
|
|
91
|
+
${renderPreview(services)} // Branch policy: per-branch tuning
|
|
92
|
+
branch: (branch) => {
|
|
93
|
+
if (branch.isDefault) {
|
|
94
|
+
// Default branch: no overrides, uses project defaults
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
if (!branch.exists) {
|
|
98
|
+
// New non-default branches: auto-expire
|
|
99
|
+
// Run \`neon checkout <name>\` to create a new branch with these settings
|
|
100
|
+
return { ttl: "7d" };
|
|
101
|
+
}
|
|
102
|
+
// Existing branch: no changes
|
|
103
|
+
return {};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
`;
|
|
107
|
+
/**
|
|
108
|
+
* The handler written alongside `neon.ts` when `functions` is selected. It has to exist:
|
|
109
|
+
* `FunctionDef.source` is only resolved when `config apply` / `deploy` bundles it, so a
|
|
110
|
+
* declared function with no file on disk fails at deploy time rather than at authoring time.
|
|
111
|
+
*
|
|
112
|
+
* A default-exported function rather than `export default { fetch }`: both are resolved (see
|
|
113
|
+
* `resolveFetchHandler`), and the bare function is less to read and less to get wrong. It is
|
|
114
|
+
* named rather than anonymous so a project's linter has nothing to say about it, and takes no
|
|
115
|
+
* parameter because a scaffold shipping an unused `req` fails a `noUnusedParameters` project.
|
|
116
|
+
*/
|
|
117
|
+
export const FUNCTION_TEMPLATE = `export default async function hello(): Promise<Response> {
|
|
118
|
+
return new Response("Hello from Neon Functions");
|
|
119
|
+
}
|
|
120
|
+
`;
|
package/dist/context.js
CHANGED
|
@@ -31,6 +31,15 @@ export const isCurrentBranchProbe = (args) => args.currentBranch === true &&
|
|
|
31
31
|
* client), mirroring {@link isCurrentBranchProbe}.
|
|
32
32
|
*/
|
|
33
33
|
export const isConfigInit = (args) => args._[0] === "config" && args._[1] === "init";
|
|
34
|
+
/**
|
|
35
|
+
* `neon profile …` manages credentials on disk and never calls the Neon API, so the global
|
|
36
|
+
* auth middleware must skip it — mirroring {@link isConfigInit}.
|
|
37
|
+
*
|
|
38
|
+
* More than a nicety: without this, listing your profiles would launch a browser login, and
|
|
39
|
+
* removing a broken profile would demand you sign into it first. Removing a profile whose
|
|
40
|
+
* access has already lapsed is the main reason to remove one.
|
|
41
|
+
*/
|
|
42
|
+
export const isProfileCommand = (args) => args._[0] === "profile" || args._[0] === "profiles";
|
|
34
43
|
const CONTEXT_FILE = ".neon";
|
|
35
44
|
const GITIGNORE_FILE = ".gitignore";
|
|
36
45
|
const canAccessFile = (file) => {
|
package/dist/index.js
CHANGED
|
@@ -67,6 +67,11 @@ builder = builder
|
|
|
67
67
|
group: "Global options:",
|
|
68
68
|
type: "string",
|
|
69
69
|
default: defaultDir,
|
|
70
|
+
})
|
|
71
|
+
.option("profile", {
|
|
72
|
+
describe: "Named credentials to use, from profiles.json (default: NEON_PROFILE, else DEFAULT)",
|
|
73
|
+
group: "Global options:",
|
|
74
|
+
type: "string",
|
|
70
75
|
})
|
|
71
76
|
.option("force-auth", {
|
|
72
77
|
describe: "Force authentication",
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # Profiles — several Neon accounts in one config directory
|
|
3
|
+
*
|
|
4
|
+
* A profile is **a pointer to a credentials file**. Nothing more. That constraint is what
|
|
5
|
+
* keeps the feature small: there is no mirror, no per-profile directory tree, no persistent
|
|
6
|
+
* "active profile" state to fall out of sync, and no migration.
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* ~/.config/neon/
|
|
10
|
+
* ├── credentials.json # this IS the DEFAULT profile, not a copy of it
|
|
11
|
+
* ├── credentials.work.json # created by `neon auth --profile work`
|
|
12
|
+
* └── profiles.json # created only once a second profile exists
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `profiles.json` maps a name to a path, and the path may point anywhere — which is what
|
|
16
|
+
* makes adopting an existing directory a one-line edit rather than an import command:
|
|
17
|
+
*
|
|
18
|
+
* ```json
|
|
19
|
+
* {
|
|
20
|
+
* "version": 1,
|
|
21
|
+
* "profiles": {
|
|
22
|
+
* "DEFAULT": { "credentials": "credentials.json" },
|
|
23
|
+
* "work": {
|
|
24
|
+
* "credentials": "../neonctl-databricks/credentials.json",
|
|
25
|
+
* "label": "someone@example.com"
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* ## Selection
|
|
32
|
+
*
|
|
33
|
+
* `--profile` → `NEON_PROFILE` → `DEFAULT`. Per invocation, like `AWS_PROFILE`; there is no
|
|
34
|
+
* `profile use` command, so nothing persists that could disagree with what you typed.
|
|
35
|
+
*
|
|
36
|
+
* ## Compatibility
|
|
37
|
+
*
|
|
38
|
+
* An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
|
|
39
|
+
* resolves to `credentials.json` in the config directory (including an existing one in the
|
|
40
|
+
* legacy `neonctl` directory — see `@neon/config/paths`). Nothing is created until a second
|
|
41
|
+
* profile is, and nothing is ever moved.
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
45
|
+
import { resolveConfigFile } from "@neon/config/paths";
|
|
46
|
+
import { credentialsPath, defaultDir } from "./config.js";
|
|
47
|
+
import { log } from "./log.js";
|
|
48
|
+
export const PROFILES_FILE = "profiles.json";
|
|
49
|
+
/** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
|
|
50
|
+
export const DEFAULT_PROFILE = "DEFAULT";
|
|
51
|
+
/** Profile names become part of a filename, so keep them boring. */
|
|
52
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
53
|
+
/** Which profile this invocation should use: `--profile` → `NEON_PROFILE` → `DEFAULT`. */
|
|
54
|
+
export const selectProfileName = (flag, env = process.env) => nonEmpty(flag) ?? nonEmpty(env.NEON_PROFILE) ?? DEFAULT_PROFILE;
|
|
55
|
+
export const assertValidProfileName = (name) => {
|
|
56
|
+
if (!NAME_PATTERN.test(name)) {
|
|
57
|
+
throw new Error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore, starting with a letter or digit.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
|
|
61
|
+
export const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
62
|
+
/**
|
|
63
|
+
* Read `profiles.json`, or `null` when there isn't one — the normal single-account state.
|
|
64
|
+
* A malformed file is reported and treated as absent rather than breaking every command;
|
|
65
|
+
* the worst case is that a named profile is "not found", which is recoverable, whereas
|
|
66
|
+
* throwing here would lock the user out of `neon auth` itself.
|
|
67
|
+
*/
|
|
68
|
+
export const readProfiles = (dir) => {
|
|
69
|
+
const path = profilesFilePath(dir);
|
|
70
|
+
if (!existsSync(path))
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
if (parsed === null ||
|
|
75
|
+
typeof parsed !== "object" ||
|
|
76
|
+
Array.isArray(parsed))
|
|
77
|
+
throw new Error("not an object");
|
|
78
|
+
const profiles = parsed.profiles;
|
|
79
|
+
if (profiles === null ||
|
|
80
|
+
typeof profiles !== "object" ||
|
|
81
|
+
Array.isArray(profiles))
|
|
82
|
+
throw new Error("missing `profiles`");
|
|
83
|
+
return { version: 1, profiles };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
log.warning("Ignoring malformed %s: %s", path, err instanceof Error ? err.message : String(err));
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
|
|
91
|
+
export const resolveProfile = (dir, name) => {
|
|
92
|
+
const file = readProfiles(dir);
|
|
93
|
+
const entry = file?.profiles[name];
|
|
94
|
+
if (entry) {
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
credentialsPath: resolveEntryPath(dir, entry.credentials),
|
|
98
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
99
|
+
...(entry.userId ? { userId: entry.userId } : {}),
|
|
100
|
+
declared: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// DEFAULT works with no profiles.json at all, and keeps working when one exists but
|
|
104
|
+
// doesn't mention it — that is the pre-profiles behaviour, unchanged.
|
|
105
|
+
if (name === DEFAULT_PROFILE) {
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
credentialsPath: credentialsPath(dir),
|
|
109
|
+
declared: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const known = file
|
|
113
|
+
? Object.keys(file.profiles).join(", ")
|
|
114
|
+
: DEFAULT_PROFILE;
|
|
115
|
+
throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon auth --profile ${name}\`.`);
|
|
116
|
+
};
|
|
117
|
+
/** Default location for a new named profile's credentials file. */
|
|
118
|
+
export const newProfileCredentialsPath = (dir, name) => resolve(dir, `credentials.${name}.json`);
|
|
119
|
+
/**
|
|
120
|
+
* Record a profile, creating `profiles.json` if this is the first named one.
|
|
121
|
+
*
|
|
122
|
+
* When the file is created, `DEFAULT` is written explicitly and pointed at wherever
|
|
123
|
+
* `credentials.json` actually is. That matters for an install predating the directory
|
|
124
|
+
* rename: `profiles.json` is created in `neon/` while the credentials are still in
|
|
125
|
+
* `neonctl/`, so `DEFAULT` is recorded as `../neonctl/credentials.json` rather than a
|
|
126
|
+
* relative name that would resolve to a file that isn't there.
|
|
127
|
+
*/
|
|
128
|
+
export const upsertProfile = (dir, name, entry) => {
|
|
129
|
+
assertValidProfileName(name);
|
|
130
|
+
const path = profilesFilePath(dir);
|
|
131
|
+
const file = readProfiles(dir) ?? {
|
|
132
|
+
version: 1,
|
|
133
|
+
profiles: {
|
|
134
|
+
[DEFAULT_PROFILE]: {
|
|
135
|
+
credentials: relativeToProfiles(path, credentialsPath(dir)),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
file.profiles[name] = {
|
|
140
|
+
credentials: relativeToProfiles(path, entry.credentials),
|
|
141
|
+
...(entry.label ? { label: entry.label } : {}),
|
|
142
|
+
...(entry.userId ? { userId: entry.userId } : {}),
|
|
143
|
+
};
|
|
144
|
+
writeProfiles(path, file);
|
|
145
|
+
};
|
|
146
|
+
/** Remove an entry. Returns false when it wasn't there. */
|
|
147
|
+
export const removeProfileEntry = (dir, name) => {
|
|
148
|
+
const path = profilesFilePath(dir);
|
|
149
|
+
const file = readProfiles(dir);
|
|
150
|
+
if (!file?.profiles[name])
|
|
151
|
+
return false;
|
|
152
|
+
delete file.profiles[name];
|
|
153
|
+
writeProfiles(path, file);
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* True when only `DEFAULT` is left, so `profiles.json` no longer earns its place. Mirrors
|
|
158
|
+
* lazy creation: a single-account install has no profiles file, before or after.
|
|
159
|
+
*/
|
|
160
|
+
export const onlyDefaultRemains = (file) => {
|
|
161
|
+
const names = Object.keys(file.profiles);
|
|
162
|
+
return (names.length === 0 ||
|
|
163
|
+
(names.length === 1 && names[0] === DEFAULT_PROFILE));
|
|
164
|
+
};
|
|
165
|
+
export const listProfiles = (dir) => {
|
|
166
|
+
const file = readProfiles(dir);
|
|
167
|
+
if (!file)
|
|
168
|
+
return [resolveProfile(dir, DEFAULT_PROFILE)];
|
|
169
|
+
const names = Object.keys(file.profiles);
|
|
170
|
+
if (!names.includes(DEFAULT_PROFILE))
|
|
171
|
+
names.unshift(DEFAULT_PROFILE);
|
|
172
|
+
return names.map((name) => resolveProfile(dir, name));
|
|
173
|
+
};
|
|
174
|
+
const writeProfiles = (path, file) => {
|
|
175
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
176
|
+
};
|
|
177
|
+
const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
|
|
178
|
+
/** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
|
|
179
|
+
const profilesDir = (dir) => resolve(profilesFilePath(dir), "..");
|
|
180
|
+
/** Keep entries relative when they sit near `profiles.json`; absolute paths stay absolute. */
|
|
181
|
+
const relativeToProfiles = (profilesPath, target) => {
|
|
182
|
+
const rel = relative(resolve(profilesPath, ".."), target);
|
|
183
|
+
return rel && !isAbsolute(rel) ? rel : target;
|
|
184
|
+
};
|
|
185
|
+
function nonEmpty(value) {
|
|
186
|
+
if (typeof value !== "string")
|
|
187
|
+
return undefined;
|
|
188
|
+
const trimmed = value.trim();
|
|
189
|
+
return trimmed === "" ? undefined : trimmed;
|
|
190
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import { NEON_SERVICES } from "../config_template.js";
|
|
3
|
+
/**
|
|
4
|
+
* The picker's rows, in {@link NEON_SERVICES} order. Titles use the product names from the
|
|
5
|
+
* CLI's README ("Managed Better Auth", "Object Storage") rather than the `neon.ts` field
|
|
6
|
+
* names, since this is the list a user reads before they've seen a policy.
|
|
7
|
+
*/
|
|
8
|
+
const CHOICES = [
|
|
9
|
+
{
|
|
10
|
+
value: "auth",
|
|
11
|
+
title: "Managed Better Auth",
|
|
12
|
+
description: "Authentication with users and sessions stored in Postgres.",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
value: "functions",
|
|
16
|
+
title: "Functions",
|
|
17
|
+
description: "Long-running, without timeouts, and closer to your database.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
value: "storage",
|
|
21
|
+
title: "Object Storage",
|
|
22
|
+
description: "S3-compatible blob storage that branches with your projects.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
value: "ai-gateway",
|
|
26
|
+
title: "AI Gateway",
|
|
27
|
+
description: "All models, one API, one bill. Powered by Databricks. Not available on the Neon free plan.",
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Ask which services the scaffolded `neon.ts` should declare. Selecting nothing is a real
|
|
32
|
+
* answer — it yields the bare starter policy — so an empty list returns empty rather than
|
|
33
|
+
* re-prompting. Aborting (Ctrl-C) exits 1, matching the prompts in `link`.
|
|
34
|
+
*
|
|
35
|
+
* Callers guard the TTY themselves (see `initCmd`); this function assumes it may prompt.
|
|
36
|
+
*/
|
|
37
|
+
export const pickServicesInteractively = async () => {
|
|
38
|
+
const { services } = await prompts({
|
|
39
|
+
onState: (state) => {
|
|
40
|
+
if (state.aborted) {
|
|
41
|
+
// Restore the cursor prompts hid, then exit — otherwise the terminal is
|
|
42
|
+
// left without one for the rest of the session.
|
|
43
|
+
process.stdout.write("\x1B[?25h");
|
|
44
|
+
process.stdout.write("\n");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
type: "multiselect",
|
|
49
|
+
name: "services",
|
|
50
|
+
message: "Which Neon services should neon.ts declare? (space to toggle, enter to confirm)",
|
|
51
|
+
instructions: false,
|
|
52
|
+
choices: CHOICES.map((choice) => ({
|
|
53
|
+
value: choice.value,
|
|
54
|
+
title: choice.title,
|
|
55
|
+
description: choice.description,
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
if (!Array.isArray(services)) {
|
|
59
|
+
throw new Error("Aborted: no services selected.");
|
|
60
|
+
}
|
|
61
|
+
// Order by NEON_SERVICES rather than selection order so the rendered neon.ts is
|
|
62
|
+
// independent of the order the rows were toggled in.
|
|
63
|
+
return NEON_SERVICES.filter((service) => services.includes(service));
|
|
64
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.41.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/config": "0.13.0",
|
|
58
59
|
"@neon/sdk": "1.4.0",
|
|
59
|
-
"@neon/config": "0.12.
|
|
60
|
-
"
|
|
61
|
-
"@neon/env": "0.13.
|
|
62
|
-
"neon-init": "0.20.5"
|
|
60
|
+
"@neon/config-runtime": "0.12.1",
|
|
61
|
+
"neon-init": "0.20.6",
|
|
62
|
+
"@neon/env": "0.13.1"
|
|
63
63
|
},
|
|
64
64
|
"optionalDependencies": {
|
|
65
65
|
"esbuild": "0.28.1"
|