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 CHANGED
@@ -456,9 +456,55 @@ export default defineConfig({
456
456
  });
457
457
  ```
458
458
 
459
- Three sub-commands plus two top-level aliases drive it:
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` | Drive a branch from `neon.ts` |
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/neonctl` |
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. To view the default configuration directory containing you `credentials.json` file, run `neon --help`. The credentials file is created when you authenticate using the `neon auth` command. This option is only necessary if you move your `neon` configuration file to a location other than the default.
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/neonctl
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 { CREDENTIALS_FILE } from "./config.js";
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 credentialsPath = join(args.configDir, CREDENTIALS_FILE);
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(), {
@@ -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 { CREDENTIALS_FILE } from "../config.js";
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
- const credentialsPath = join(configDir, CREDENTIALS_FILE);
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
- // correctly sets needed permissions for the credentials file
87
+ // Owner-only. A credentials file needs read/write, never execute.
53
88
  writeFileSync(path, contents, {
54
- mode: 0o700,
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 = join(props.configDir, CREDENTIALS_FILE);
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
- * Deletes the credentials file at the specified path
208
- * @param configDir Directory where credentials file is stored
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 = join(configDir, CREDENTIALS_FILE);
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);
@@ -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
- writeFileSync(join(cwd, "neon.ts"), NEON_CONFIG_TEMPLATE);
161
- log.info("Created neon.ts with a starter policy.");
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;
@@ -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 { homedir } from "node:os";
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
- export const defaultDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "neonctl");
7
- export const ensureConfigDir = ({ "config-dir": configDir, "force-auth": forceAuth, }) => {
8
- if (!existsSync(configDir) && (!isCi() || forceAuth)) {
9
- mkdirSync(configDir, { recursive: true });
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",
@@ -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.39.1",
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.0",
60
- "@neon/config-runtime": "0.12.0",
61
- "@neon/env": "0.13.0",
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"