neon 2.41.0 → 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.
@@ -5,7 +5,7 @@ import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranch
5
5
  import chalk from "chalk";
6
6
  import { getApiClient } from "../api.js";
7
7
  import { toNeonConfigView } from "../config_format.js";
8
- import { FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, NEON_SERVICES, NO_SERVICES, parseServices, REQUIRED_PACKAGES, renderNeonConfig, } from "../config_template.js";
8
+ import { FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, NEON_SERVICES, NO_SERVICES, parseServices, REQUIRED_PACKAGES, renderNeonConfig, renderNeonConfigFromView, } from "../config_template.js";
9
9
  import { contextBranch, readContextFile } from "../context.js";
10
10
  import { isCi } from "../env.js";
11
11
  import { loadEnvFileIntoProcess } from "../env_file.js";
@@ -143,10 +143,39 @@ const scaffoldFunction = (cwd) => {
143
143
  writeFileSync(path, FUNCTION_TEMPLATE);
144
144
  log.info("Created %s — the source of the %s function.", FUNCTION_FILENAME, FUNCTION_SLUG);
145
145
  };
146
+ /**
147
+ * Read a branch's live state and render it as a `neon.ts`. The read goes through the same
148
+ * {@link liveConfigView} as `config status`, so a seeded policy declares exactly what
149
+ * `config status --config-json` reports — including what it cannot report (see
150
+ * {@link renderNeonConfigFromView}).
151
+ */
152
+ const seedFromBranch = async (props) => {
153
+ const { apiClient, projectId } = props;
154
+ if (!apiClient || !projectId) {
155
+ throw new Error("--from-branch needs a project. Pass --project-id, or run `neon link` to pin one in .neon.");
156
+ }
157
+ const ref = await resolveBranchRef({
158
+ apiClient,
159
+ projectId,
160
+ ...(props.branch !== undefined ? { branch: props.branch } : {}),
161
+ });
162
+ if (ref.usedDefault) {
163
+ log.info("No branch pinned or passed — seeding from the project's default branch %s.", ref.branchName);
164
+ }
165
+ const { live, view } = await liveConfigView({
166
+ projectId,
167
+ branchId: ref.branchId,
168
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
169
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
170
+ ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
171
+ });
172
+ const rendered = renderNeonConfigFromView(view, live.branch.name);
173
+ return { ...rendered, branchName: live.branch.name };
174
+ };
146
175
  /**
147
176
  * Scaffold a `neon.ts` policy and make sure the Neon config packages are
148
177
  * installed, so a project can go straight to `neon config plan` / `apply`.
149
- * 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();
@@ -158,6 +187,16 @@ export const initCmd = async (props) => {
158
187
  if (existing) {
159
188
  log.info("Found an existing %s — leaving it untouched.", existing);
160
189
  }
190
+ else if (props.fromBranch) {
191
+ const { source, seeded, branchName } = await seedFromBranch(props);
192
+ writeFileSync(join(cwd, "neon.ts"), source);
193
+ if (seeded) {
194
+ log.info("Created neon.ts from the live state of %s.", branchName);
195
+ }
196
+ else {
197
+ log.info("%s declares no services and no branch settings — created neon.ts with the starter policy instead.", branchName);
198
+ }
199
+ }
161
200
  else {
162
201
  const services = await resolveServices(props);
163
202
  writeFileSync(join(cwd, "neon.ts"), renderNeonConfig(services));
@@ -251,10 +290,47 @@ export const builder = (argv) => argv
251
290
  "terminal, starter policy in CI or without a TTY.",
252
291
  type: "string",
253
292
  },
293
+ "from-branch": {
294
+ describe: "Seed neon.ts from a branch's live Neon state instead of asking. Uses the " +
295
+ "branch pinned in .neon, or --branch <name|id>, or the project's default " +
296
+ "branch. The only mode of `config init` that calls the Neon API.",
297
+ type: "boolean",
298
+ // No `default`: yargs counts a defaulted key as provided, so
299
+ // `default: false` makes `conflicts` reject every `--services` run.
300
+ conflicts: "services",
301
+ },
254
302
  }), (args) => initCmd(args));
255
303
  export const handler = (args) => {
256
304
  return args;
257
305
  };
306
+ /**
307
+ * A branch's live state, plus that state projected into the `neon.ts`-shaped
308
+ * {@link NeonConfigView}. Shared by `config status` and `config init --from-branch` so both
309
+ * read the branch through one path: what `status --config-json` prints is exactly what
310
+ * `init --from-branch` writes.
311
+ *
312
+ * The pulled `config` carries the branch's tuning inside a closure that JSON can't render, so
313
+ * it is resolved against the live branch target first.
314
+ */
315
+ const liveConfigView = async (opts) => {
316
+ const live = await inspect({
317
+ projectId: opts.projectId,
318
+ branchId: opts.branchId,
319
+ ...(opts.apiKey ? { apiKey: opts.apiKey } : {}),
320
+ ...(opts.apiHost ? { apiHost: opts.apiHost } : {}),
321
+ ...(opts.runtimeApi ? { api: opts.runtimeApi } : {}),
322
+ });
323
+ const resolved = resolveConfig(live.config, {
324
+ name: live.branch.name,
325
+ id: live.branch.id,
326
+ exists: true,
327
+ isDefault: live.branch.isDefault,
328
+ isProtected: live.branch.protected,
329
+ ...(live.branch.parent ? { parentId: live.branch.parent } : {}),
330
+ ...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
331
+ });
332
+ return { live, view: toNeonConfigView(resolved, live.preview) };
333
+ };
258
334
  const loadConfig = async (props) => {
259
335
  // Load the optional --env file FIRST so a `neon.ts` whose function `env` values read
260
336
  // `process.env.X` sees them. Must happen before the policy module is imported/evaluated.
@@ -289,27 +365,13 @@ export const status = async (props) => {
289
365
  if (!props.configJson) {
290
366
  announceTargetBranch(props, branch, "Inspecting branch");
291
367
  }
292
- const branchId = branch.branchId;
293
- const live = await inspect({
368
+ const { live, view: configView } = await liveConfigView({
294
369
  projectId: props.projectId,
295
- branchId,
370
+ branchId: branch.branchId,
296
371
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
297
372
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
298
- ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
299
- });
300
- // The pulled `config` carries the branch's tuning inside a closure that JSON can't
301
- // render. Resolve it against the live branch target to get the concrete settings, then
302
- // project both that and the separately-pulled preview state into a neon.ts-shaped view.
303
- const resolved = resolveConfig(live.config, {
304
- name: live.branch.name,
305
- id: live.branch.id,
306
- exists: true,
307
- isDefault: live.branch.isDefault,
308
- isProtected: live.branch.protected,
309
- ...(live.branch.parent ? { parentId: live.branch.parent } : {}),
310
- ...(live.branch.expiresAt ? { expiresAt: live.branch.expiresAt } : {}),
373
+ ...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
311
374
  });
312
- const configView = toNeonConfigView(resolved, live.preview);
313
375
  // `--config-json`: emit just the neon.ts-shaped config to stdout (script-friendly,
314
376
  // copy-paste-able), regardless of the global --output.
315
377
  if (props.configJson) {
@@ -60,22 +60,43 @@ export const parseServices = (raw) => {
60
60
  }
61
61
  return NEON_SERVICES.filter((service) => names.includes(service));
62
62
  };
63
+ /**
64
+ * One indentation level in the emitted `neon.ts`. Two spaces, which is what every renderer
65
+ * here produces and what `config_template.format.test.ts` holds them to.
66
+ */
67
+ const INDENT = " ";
68
+ /**
69
+ * Prefix each line with `level` indentation levels. Nesting is expressed as a number at the
70
+ * one place that knows the structure, rather than as literal spaces at every push site — a
71
+ * miscounted space is otherwise invisible in review and only shows up in a user's file.
72
+ */
73
+ const at = (level, ...lines) => lines.map((line) => INDENT.repeat(level) + line);
74
+ /** Wrap `body` in an object-literal block named `key`, indented from `level`. */
75
+ const block = (level, key, body) => [
76
+ ...at(level, `${key}: {`),
77
+ ...body,
78
+ ...at(level, "},"),
79
+ ];
63
80
  /** The `preview` block for the selected services, or "" when none of them is a preview feature. */
64
81
  const renderPreview = (services) => {
65
82
  const lines = [];
66
83
  if (services.includes("ai-gateway")) {
67
- lines.push(" aiGateway: true,");
84
+ lines.push(...at(2, "aiGateway: true,"));
68
85
  }
69
86
  if (services.includes("functions")) {
70
- lines.push(" functions: {", ` ${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`, " },");
87
+ lines.push(...block(2, "functions", [
88
+ ...at(3, `${FUNCTION_SLUG}: { name: "${FUNCTION_NAME}", source: "./${FUNCTION_FILENAME}" },`),
89
+ ]));
71
90
  }
72
91
  if (services.includes("storage")) {
73
- lines.push(" buckets: {", ` // "private" is the default; use "public_read" for anonymous reads`, ` ${BUCKET_NAME}: { access: "private" },`, " },");
92
+ lines.push(...block(2, "buckets", [
93
+ ...at(3, `// "private" is the default; use "public_read" for anonymous reads`, `${BUCKET_NAME}: { access: "private" },`),
94
+ ]));
74
95
  }
75
96
  if (lines.length === 0) {
76
97
  return "";
77
98
  }
78
- return `${[" preview: {", ...lines, " },"].join("\n")}\n`;
99
+ return `${block(1, "preview", lines).join("\n")}\n`;
79
100
  };
80
101
  /**
81
102
  * Render the `neon.ts` policy `config init` writes. With no services this is the starter
@@ -104,6 +125,107 @@ ${renderPreview(services)} // Branch policy: per-branch tuning
104
125
  },
105
126
  });
106
127
  `;
128
+ /** Render a scalar the way it has to appear in TypeScript source. */
129
+ const renderScalar = (value) => typeof value === "string" ? `"${value}"` : String(value);
130
+ /**
131
+ * Render an object key. Live names are not identifiers: a Neon bucket may be called
132
+ * `smoke-uploads`, which as a bare key is a subtraction and a syntax error. Anything that
133
+ * isn't a plain identifier gets quoted.
134
+ */
135
+ const renderKey = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
136
+ /**
137
+ * The `branch` closure for a policy seeded from live state, or "" when the branch carries no
138
+ * tuning worth declaring.
139
+ *
140
+ * `protected` is read but never declared: it is a fact about one branch, while a policy
141
+ * `protected` applies to every branch the policy runs against. It becomes a comment instead.
142
+ */
143
+ const renderSeededBranch = (view) => {
144
+ const settings = [];
145
+ if (view.branch?.parent !== undefined) {
146
+ settings.push(...at(2, `parent: ${renderScalar(view.branch.parent)},`));
147
+ }
148
+ if (view.branch?.ttl !== undefined) {
149
+ settings.push(...at(2, `ttl: ${renderScalar(view.branch.ttl)},`));
150
+ }
151
+ const compute = view.branch?.postgres?.computeSettings;
152
+ const computeFields = Object.entries(compute ?? {}).filter(([, value]) => value !== undefined);
153
+ if (computeFields.length > 0) {
154
+ settings.push(...block(2, "postgres", [
155
+ ...block(3, "computeSettings", computeFields.flatMap(([field, value]) => at(4, `${field}: ${renderScalar(value)},`))),
156
+ ]));
157
+ }
158
+ if (settings.length === 0) {
159
+ return "";
160
+ }
161
+ // An arrow returning an object literal, so the closing line is `}),` rather than `},`.
162
+ return `${[...at(1, "branch: () => ({"), ...settings, ...at(1, "}),")].join("\n")}\n`;
163
+ };
164
+ /** The `preview` block for a policy seeded from live state. */
165
+ const renderSeededPreview = (view, branchName) => {
166
+ const lines = [];
167
+ const buckets = Object.entries(view.preview?.buckets ?? {});
168
+ if (buckets.length > 0) {
169
+ lines.push(...block(2, "buckets", buckets.flatMap(([name, bucket]) => at(3, `${renderKey(name)}: { access: "${bucket.access}" },`))));
170
+ }
171
+ // A deployed function cannot be declared from live state: `source` is a path in the
172
+ // user's project and the branch only knows the uploaded bundle. Listing the slugs as a
173
+ // commented-out block is the most a read-back can honestly produce.
174
+ const functions = Object.entries(view.preview?.functions ?? {});
175
+ if (functions.length > 0) {
176
+ lines.push(...at(2, `// ${branchName} has ${functions.length} deployed function${functions.length === 1 ? "" : "s"}.`, "// Declaring one needs the local source path, which the branch does not know:", "// functions: {", ...functions.map(([slug, fn]) => `// ${renderKey(slug)}: { name: "${fn.name}", source: "./${slug}.ts" },`), "// },"));
177
+ }
178
+ if (lines.length === 0) {
179
+ return "";
180
+ }
181
+ return `${block(1, "preview", lines).join("\n")}\n`;
182
+ };
183
+ /**
184
+ * Render a `neon.ts` from a branch's live state (`config init --from-branch`).
185
+ *
186
+ * Only what the branch can actually report is declared. Three things are deliberately
187
+ * absent, each for its own reason:
188
+ *
189
+ * - **The AI Gateway** has no branch-level enabled state to read — it is always available and
190
+ * credential-gated — so `pullConfig` cannot tell whether a policy would enable it.
191
+ * - **Functions** cannot round-trip (no `source` path on the remote); they are listed as a
192
+ * commented-out block.
193
+ * - **`protected`** is branch state rather than policy intent, so it is reported as a comment.
194
+ *
195
+ * A branch with nothing to report (no services, no tuning) renders the starter policy rather
196
+ * than an empty `defineConfig({})`: seeding found nothing, and the caller says so.
197
+ */
198
+ export const renderNeonConfigFromView = (view, branchName) => {
199
+ const services = [
200
+ view.auth ? " auth: true," : "",
201
+ view.dataApi ? " dataApi: true," : "",
202
+ ].filter((line) => line !== "");
203
+ const preview = renderSeededPreview(view, branchName);
204
+ const branch = renderSeededBranch(view);
205
+ if (services.length === 0 && preview === "" && branch === "") {
206
+ return { source: renderNeonConfig([]), seeded: false };
207
+ }
208
+ const protectedNote = view.branch?.protected
209
+ ? `// ${branchName} is protected on Neon. Not declared here: a policy \`protected\` would\n// apply to every branch this policy is applied to.\n`
210
+ : "";
211
+ const body = [
212
+ ...services,
213
+ ...(preview === "" ? [] : [preview.trimEnd()]),
214
+ ...(branch === "" ? [] : [branch.trimEnd()]),
215
+ ].join("\n");
216
+ return {
217
+ source: `import { defineConfig } from "${CONFIG_PACKAGE}/v1";
218
+
219
+ // Seeded by \`neon config init --from-branch\` from ${branchName}.
220
+ // The AI Gateway is not readable from a branch (always available, credential-gated), so add
221
+ // \`preview: { aiGateway: true }\` if the policy should declare it.
222
+ ${protectedNote}export default defineConfig({
223
+ ${body}
224
+ });
225
+ `,
226
+ seeded: true,
227
+ };
228
+ };
107
229
  /**
108
230
  * The handler written alongside `neon.ts` when `functions` is selected. It has to exist:
109
231
  * `FunctionDef.source` is only resolved when `config apply` / `deploy` bundles it, so a
package/dist/context.js CHANGED
@@ -29,8 +29,17 @@ export const isCurrentBranchProbe = (args) => args.currentBranch === true &&
29
29
  * never calls the Neon API. Gated on the exact command path so the global auth
30
30
  * middleware and the single-project resolver can skip it (it runs with no API
31
31
  * client), mirroring {@link isCurrentBranchProbe}.
32
+ *
33
+ * `--from-branch` is the exception: it seeds the policy from a branch's live state, so it
34
+ * needs both credentials and a resolved project. The raw argv is checked alongside the parsed
35
+ * flag because this runs from middleware that executes before validation, where the parsed
36
+ * value may not be populated yet (the same reason `analytics.ts` scans argv for
37
+ * `--current-branch`).
32
38
  */
33
- export const isConfigInit = (args) => args._[0] === "config" && args._[1] === "init";
39
+ export const isConfigInit = (args) => args._[0] === "config" &&
40
+ args._[1] === "init" &&
41
+ args.fromBranch !== true &&
42
+ !process.argv.includes("--from-branch");
34
43
  /**
35
44
  * `neon profile …` manages credentials on disk and never calls the Neon API, so the global
36
45
  * auth middleware must skip it — mirroring {@link isConfigInit}.
@@ -42,9 +42,7 @@ export const branchIdFromProps = async (props) => {
42
42
  return props.branchId;
43
43
  };
44
44
  export const resolveBranchRef = async (props) => {
45
- const branch = "branch" in props && typeof props.branch === "string"
46
- ? props.branch
47
- : props.id;
45
+ const branch = typeof props.branch === "string" ? props.branch : props.id;
48
46
  const { data } = await props.apiClient.listProjectBranches({
49
47
  projectId: props.projectId,
50
48
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "2.41.0",
3
+ "version": "2.42.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",
59
- "@neon/sdk": "1.4.0",
60
- "@neon/config-runtime": "0.12.1",
61
- "neon-init": "0.20.6",
62
- "@neon/env": "0.13.1"
58
+ "@neon/sdk": "1.4.1",
59
+ "@neon/config": "0.13.1",
60
+ "@neon/config-runtime": "0.12.2",
61
+ "@neon/env": "0.13.2",
62
+ "neon-init": "0.20.6"
63
63
  },
64
64
  "optionalDependencies": {
65
65
  "esbuild": "0.28.1"