neon 2.39.1 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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, renderNeonConfigFromView, } from "../config_template.js";
8
9
  import { contextBranch, readContextFile } from "../context.js";
9
10
  import { isCi } from "../env.js";
10
11
  import { loadEnvFileIntoProcess } from "../env_file.js";
@@ -15,6 +16,7 @@ import { renderAppliedChanges, renderBranchSettingConflicts, } from "../utils/co
15
16
  import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
16
17
  import { bundleEntry } from "../utils/esbuild.js";
17
18
  import { addDependenciesArgs, resolvePackageManager, runCommand, } from "../utils/package_manager.js";
19
+ import { pickServicesInteractively } from "../utils/service_picker.js";
18
20
  import { zipBundle } from "../utils/zip.js";
19
21
  import { writer } from "../writer.js";
20
22
  import { autoPullEnvAfterPin } from "./env.js";
@@ -69,18 +71,6 @@ export const envPullFlag = {
69
71
  },
70
72
  };
71
73
  // ── `config init` ─────────────────────────────────────────────────────────────
72
- /**
73
- * The published npm packages a `neon.ts` project needs — the `@neon/*` org names.
74
- *
75
- * ⚠️ These ship to users the next time `neonctl` is released, so do NOT release
76
- * neonctl until `@neon/config` and `@neon/env` are published to npm — otherwise
77
- * `config init` would install packages that don't exist yet. (The libraries are
78
- * mid-migration from `@neondatabase/*`; track their publish before cutting a CLI
79
- * release.)
80
- */
81
- const CONFIG_PACKAGE = "@neon/config";
82
- const ENV_PACKAGE = "@neon/env";
83
- const REQUIRED_PACKAGES = [CONFIG_PACKAGE, ENV_PACKAGE];
84
74
  /** package.json fields a dependency can be declared in. */
85
75
  const DEPENDENCY_FIELDS = [
86
76
  "dependencies",
@@ -92,28 +82,6 @@ const DEPENDENCY_FIELDS = [
92
82
  const NEON_CONFIG_FILENAMES = ["neon.ts", "neon.mts", "neon.js", "neon.mjs"];
93
83
  /** Whether `dir` already has a Neon config file the runtime would load. */
94
84
  export const hasNeonConfigFile = (dir) => NEON_CONFIG_FILENAMES.some((name) => existsSync(join(dir, name)));
95
- /** Starter `neon.ts` written by `config init` when a project has none. */
96
- const NEON_CONFIG_TEMPLATE = `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
97
-
98
- export default defineConfig({
99
- // Declare your Neon services here
100
- auth: false,
101
- // Branch policy: per-branch tuning
102
- branch: (branch) => {
103
- if (branch.isDefault) {
104
- // Default branch: no overrides, uses project defaults
105
- return {};
106
- }
107
- if (!branch.exists) {
108
- // New non-default branches: auto-expire
109
- // Run \`neon checkout <name>\` to create a new branch with these settings
110
- return { ttl: "7d" };
111
- }
112
- // Existing branch: no changes
113
- return {};
114
- },
115
- });
116
- `;
117
85
  const isRecord = (value) => typeof value === "object" && value !== null;
118
86
  /**
119
87
  * The {@link REQUIRED_PACKAGES} not already declared in the project's package.json
@@ -143,22 +111,104 @@ const missingDependencies = (cwd) => {
143
111
  }
144
112
  return REQUIRED_PACKAGES.filter((pkg) => !declared.has(pkg));
145
113
  };
114
+ /**
115
+ * Which services the scaffolded policy declares. `--services` always wins so a script or an
116
+ * agent gets the same result without a TTY; an injected picker is next (tests); the real
117
+ * picker only runs on an interactive terminal outside CI. Everything else scaffolds the
118
+ * starter policy, which is what `config init` has always written.
119
+ */
120
+ const resolveServices = async (props) => {
121
+ if (props.services !== undefined) {
122
+ return parseServices(props.services);
123
+ }
124
+ if (props.pickServices) {
125
+ return props.pickServices();
126
+ }
127
+ if (isCi() || !process.stdout.isTTY) {
128
+ return [];
129
+ }
130
+ return pickServicesInteractively();
131
+ };
132
+ /**
133
+ * Write the hello-world handler the scaffolded `preview.functions` entry points at. An
134
+ * existing `hello.ts` is left alone: the declared function keeps pointing at it, which is the
135
+ * better outcome than overwriting a file the user wrote.
136
+ */
137
+ const scaffoldFunction = (cwd) => {
138
+ const path = join(cwd, FUNCTION_FILENAME);
139
+ if (existsSync(path)) {
140
+ log.info("Found an existing %s — leaving it untouched; the %s function points at it.", FUNCTION_FILENAME, FUNCTION_SLUG);
141
+ return;
142
+ }
143
+ writeFileSync(path, FUNCTION_TEMPLATE);
144
+ log.info("Created %s — the source of the %s function.", FUNCTION_FILENAME, FUNCTION_SLUG);
145
+ };
146
+ /**
147
+ * Read a branch's live state and render it as a `neon.ts`. The read goes through the same
148
+ * {@link liveConfigView} as `config status`, so a seeded policy declares exactly what
149
+ * `config status --config-json` reports — including what it cannot report (see
150
+ * {@link renderNeonConfigFromView}).
151
+ */
152
+ const seedFromBranch = async (props) => {
153
+ const { apiClient, projectId } = props;
154
+ if (!apiClient || !projectId) {
155
+ throw new Error("--from-branch needs a project. Pass --project-id, or run `neon link` to pin one in .neon.");
156
+ }
157
+ const ref = await resolveBranchRef({
158
+ apiClient,
159
+ projectId,
160
+ ...(props.branch !== undefined ? { branch: props.branch } : {}),
161
+ });
162
+ if (ref.usedDefault) {
163
+ log.info("No branch pinned or passed — seeding from the project's default branch %s.", ref.branchName);
164
+ }
165
+ const { live, view } = await liveConfigView({
166
+ projectId,
167
+ branchId: ref.branchId,
168
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
169
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
170
+ ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
171
+ });
172
+ const rendered = renderNeonConfigFromView(view, live.branch.name);
173
+ return { ...rendered, branchName: live.branch.name };
174
+ };
146
175
  /**
147
176
  * Scaffold a `neon.ts` policy and make sure the Neon config packages are
148
177
  * installed, so a project can go straight to `neon config plan` / `apply`.
149
- * Purely local — it never touches the Neon API (see {@link isConfigInit}).
178
+ * Local-only unless `--from-branch` is set (see {@link isConfigInit}).
150
179
  */
151
180
  export const initCmd = async (props) => {
152
181
  const cwd = props.cwd ?? process.cwd();
153
182
  const run = props.run ?? runCommand;
154
- // 1. Scaffold neon.ts unless the project already has a Neon config file.
183
+ // 1. Scaffold neon.ts unless the project already has a Neon config file. Resolving the
184
+ // services (which may prompt) happens only when there is something to write — asking
185
+ // which services to declare and then declaring nothing would be a lie.
155
186
  const existing = NEON_CONFIG_FILENAMES.find((name) => existsSync(join(cwd, name)));
156
187
  if (existing) {
157
188
  log.info("Found an existing %s — leaving it untouched.", existing);
158
189
  }
190
+ else if (props.fromBranch) {
191
+ const { source, seeded, branchName } = await seedFromBranch(props);
192
+ writeFileSync(join(cwd, "neon.ts"), source);
193
+ if (seeded) {
194
+ log.info("Created neon.ts from the live state of %s.", branchName);
195
+ }
196
+ else {
197
+ log.info("%s declares no services and no branch settings — created neon.ts with the starter policy instead.", branchName);
198
+ }
199
+ }
159
200
  else {
160
- writeFileSync(join(cwd, "neon.ts"), NEON_CONFIG_TEMPLATE);
161
- log.info("Created neon.ts with a starter policy.");
201
+ const services = await resolveServices(props);
202
+ writeFileSync(join(cwd, "neon.ts"), renderNeonConfig(services));
203
+ if (services.length === 0) {
204
+ log.info("Created neon.ts with a starter policy.");
205
+ }
206
+ else {
207
+ log.info("Created neon.ts declaring %s.", services.join(", "));
208
+ }
209
+ if (services.includes("functions")) {
210
+ scaffoldFunction(cwd);
211
+ }
162
212
  }
163
213
  // 2. Make sure the config packages are installed.
164
214
  const missing = missingDependencies(cwd);
@@ -234,10 +284,53 @@ export const builder = (argv) => argv
234
284
  type: "boolean",
235
285
  default: true,
236
286
  },
287
+ services: {
288
+ describe: `Services the scaffolded neon.ts declares, comma-separated: ${NEON_SERVICES.join(", ")}. ` +
289
+ `Pass "${NO_SERVICES}" for the bare starter policy. Omitted: pick interactively on a ` +
290
+ "terminal, starter policy in CI or without a TTY.",
291
+ type: "string",
292
+ },
293
+ "from-branch": {
294
+ describe: "Seed neon.ts from a branch's live Neon state instead of asking. Uses the " +
295
+ "branch pinned in .neon, or --branch <name|id>, or the project's default " +
296
+ "branch. The only mode of `config init` that calls the Neon API.",
297
+ type: "boolean",
298
+ // No `default`: yargs counts a defaulted key as provided, so
299
+ // `default: false` makes `conflicts` reject every `--services` run.
300
+ conflicts: "services",
301
+ },
237
302
  }), (args) => initCmd(args));
238
303
  export const handler = (args) => {
239
304
  return args;
240
305
  };
306
+ /**
307
+ * A branch's live state, plus that state projected into the `neon.ts`-shaped
308
+ * {@link NeonConfigView}. Shared by `config status` and `config init --from-branch` so both
309
+ * read the branch through one path: what `status --config-json` prints is exactly what
310
+ * `init --from-branch` writes.
311
+ *
312
+ * The pulled `config` carries the branch's tuning inside a closure that JSON can't render, so
313
+ * it is resolved against the live branch target first.
314
+ */
315
+ const liveConfigView = async (opts) => {
316
+ const live = await inspect({
317
+ projectId: opts.projectId,
318
+ branchId: opts.branchId,
319
+ ...(opts.apiKey ? { apiKey: opts.apiKey } : {}),
320
+ ...(opts.apiHost ? { apiHost: opts.apiHost } : {}),
321
+ ...(opts.runtimeApi ? { api: opts.runtimeApi } : {}),
322
+ });
323
+ const resolved = resolveConfig(live.config, {
324
+ name: live.branch.name,
325
+ id: live.branch.id,
326
+ exists: true,
327
+ isDefault: live.branch.isDefault,
328
+ isProtected: live.branch.protected,
329
+ ...(live.branch.parent ? { parentId: live.branch.parent } : {}),
330
+ ...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
331
+ });
332
+ return { live, view: toNeonConfigView(resolved, live.preview) };
333
+ };
241
334
  const loadConfig = async (props) => {
242
335
  // Load the optional --env file FIRST so a `neon.ts` whose function `env` values read
243
336
  // `process.env.X` sees them. Must happen before the policy module is imported/evaluated.
@@ -272,27 +365,13 @@ export const status = async (props) => {
272
365
  if (!props.configJson) {
273
366
  announceTargetBranch(props, branch, "Inspecting branch");
274
367
  }
275
- const branchId = branch.branchId;
276
- const live = await inspect({
368
+ const { live, view: configView } = await liveConfigView({
277
369
  projectId: props.projectId,
278
- branchId,
370
+ branchId: branch.branchId,
279
371
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
280
372
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
281
- ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
282
- });
283
- // The pulled `config` carries the branch's tuning inside a closure that JSON can't
284
- // render. Resolve it against the live branch target to get the concrete settings, then
285
- // project both that and the separately-pulled preview state into a neon.ts-shaped view.
286
- const resolved = resolveConfig(live.config, {
287
- name: live.branch.name,
288
- id: live.branch.id,
289
- exists: true,
290
- isDefault: live.branch.isDefault,
291
- isProtected: live.branch.protected,
292
- ...(live.branch.parent ? { parentId: live.branch.parent } : {}),
293
- ...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
373
+ ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
294
374
  });
295
- const configView = toNeonConfigView(resolved, live.preview);
296
375
  // `--config-json`: emit just the neon.ts-shaped config to stdout (script-friendly,
297
376
  // copy-paste-able), regardless of the global --output.
298
377
  if (props.configJson) {
@@ -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,