neonctl 2.24.1 → 2.25.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.
@@ -1,16 +1,21 @@
1
- import { createWriteStream } from 'node:fs';
2
- import { unlink } from 'node:fs/promises';
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import { stat, unlink } from 'node:fs/promises';
3
3
  import { basename } from 'node:path';
4
4
  import { pipeline } from 'node:stream/promises';
5
- import { isAxiosError } from 'axios';
5
+ import axios, { isAxiosError } from 'axios';
6
6
  import { retryOnLock } from '../api.js';
7
7
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
8
8
  import { log } from '../log.js';
9
9
  import { writer } from '../writer.js';
10
- import { createProjectBranchBucket, listProjectBranchBuckets, deleteProjectBranchBucket, listProjectBranchBucketObjects, getProjectBranchBucketObject, deleteProjectBranchBucketObject, deleteProjectBranchBucketObjectsByPrefix, } from '../storage_api.js';
10
+ import { createProjectBranchBucket, listProjectBranchBuckets, deleteProjectBranchBucket, listProjectBranchBucketObjects, getProjectBranchBucketObject, deleteProjectBranchBucketObject, deleteProjectBranchBucketObjectsByPrefix, presignUpload, } from '../storage_api.js';
11
11
  const OBJECT_FIELDS = ['key', 'size', 'last_modified', 'etag'];
12
12
  const BUCKET_FIELDS = ['name', 'access_level'];
13
13
  const ACCESS_LEVELS = ['private', 'public_read'];
14
+ // Single-PUT upload cap. Objects larger than this must use multipart upload,
15
+ // which is out of scope for v1; we reject them client-side before any HTTP so
16
+ // the user gets an immediate, clear error rather than a server-side rejection
17
+ // part-way through a large transfer.
18
+ const MAX_OBJECT_BYTES = 100 * 1024 * 1024; // 100 MB
14
19
  // Ambient scope shared by every bucket sub-command. The bucket name (and the
15
20
  // object key/prefix) is always a positional, never a flag.
16
21
  const scopeOptions = {
@@ -85,7 +90,7 @@ export const builder = (argv) => argv
85
90
  .options(scopeOptions),
86
91
  handler: (args) => deleteBucket(args),
87
92
  })
88
- .command('object <sub-command>', 'List, download or delete objects in a bucket', (yargs) => yargs
93
+ .command('object <sub-command>', 'List, download, upload or delete objects in a bucket', (yargs) => yargs
89
94
  .usage('$0 bucket object <sub-command> [options]')
90
95
  .command({
91
96
  command: 'list <target>',
@@ -129,6 +134,25 @@ export const builder = (argv) => argv
129
134
  type: 'string',
130
135
  },
131
136
  }), (args) => getObject(args))
137
+ .command('put <target>', 'Upload a local file to a bucket as an object', (yargs) => yargs
138
+ .usage('$0 bucket object put <bucket>/<key> [options]')
139
+ .positional('target', {
140
+ describe: 'The object to upload to: <bucket>/<key>',
141
+ type: 'string',
142
+ demandOption: true,
143
+ })
144
+ .options({
145
+ ...scopeOptions,
146
+ file: {
147
+ describe: 'Path to the local file to upload',
148
+ type: 'string',
149
+ demandOption: true,
150
+ },
151
+ 'content-type': {
152
+ describe: 'Content-Type to store the object with (e.g. text/plain)',
153
+ type: 'string',
154
+ },
155
+ }), (args) => putObject(args))
132
156
  .command({
133
157
  command: 'delete <target>',
134
158
  aliases: ['rm'],
@@ -328,6 +352,95 @@ const getObject = async (props) => {
328
352
  }
329
353
  log.info(`Object "${key}" downloaded from bucket "${bucket}" on branch ${branchId} to ${destination}`);
330
354
  };
355
+ const putObject = async (props) => {
356
+ const branchId = await branchIdFromProps(props);
357
+ const { bucket, rest: key } = splitBucketTarget(props.target);
358
+ if (bucket === '' || key === '') {
359
+ throw new Error('Object target must be in the form <bucket>/<key>.');
360
+ }
361
+ // Stat the file first so we fail fast on a missing/unreadable file and can
362
+ // enforce the single-PUT size cap BEFORE any network round-trip. We also
363
+ // reuse the byte count as the PUT Content-Length so the stream is uploaded
364
+ // without buffering the whole file in memory.
365
+ let fileSize;
366
+ try {
367
+ const fileStat = await stat(props.file);
368
+ if (!fileStat.isFile()) {
369
+ throw new Error(`"${props.file}" is not a regular file.`);
370
+ }
371
+ fileSize = fileStat.size;
372
+ }
373
+ catch (err) {
374
+ if (err?.code === 'ENOENT') {
375
+ throw new Error(`File "${props.file}" does not exist.`);
376
+ }
377
+ throw err;
378
+ }
379
+ if (fileSize > MAX_OBJECT_BYTES) {
380
+ throw new Error(`File "${props.file}" is ${fileSize} bytes, which exceeds the ${MAX_OBJECT_BYTES}-byte (100 MB) single-upload limit. Larger objects are not supported yet.`);
381
+ }
382
+ // Ask the console for a presigned PUT URL plus the headers that must travel
383
+ // with the upload for the signature to verify. No SigV4 happens in neonctl.
384
+ let presign;
385
+ try {
386
+ ({ data: presign } = await presignUpload(props.apiClient, {
387
+ projectId: props.projectId,
388
+ branchId,
389
+ bucketName: bucket,
390
+ objectKey: key,
391
+ contentType: props.contentType,
392
+ }));
393
+ }
394
+ catch (err) {
395
+ if (isAxiosError(err)) {
396
+ const status = err.response?.status;
397
+ if (status === 404) {
398
+ throw new Error(objectNotFoundMessage(err, key, bucket, branchId));
399
+ }
400
+ // Any other HTTP error from the console (e.g. 403 when the caller lacks
401
+ // write permission on the bucket) carries the same JSON `{ message }`
402
+ // body, so surface that rather than a bare axios message. When the body
403
+ // has no usable message, fall back to a clean status-bearing error.
404
+ const serverMessage = serverErrorMessage(err.response?.data);
405
+ throw new Error(serverMessage ??
406
+ `Failed to presign upload for "${key}" in bucket "${bucket}" on branch ${branchId}${status !== undefined ? ` (HTTP ${status})` : ''}: ${err.message}`);
407
+ }
408
+ throw err;
409
+ }
410
+ // Stream the file straight into the PUT body; never buffer the whole file.
411
+ // The presigned URL targets the branch S3 data-plane endpoint directly, so
412
+ // this PUT goes through a plain axios call rather than the console api-client.
413
+ //
414
+ // `presign.headers` carries the signature-relevant headers (e.g. host,
415
+ // content-type); the server does not sign Content-Length, so we set it
416
+ // ourselves from the stat'd size to keep the upload streamed, not chunked.
417
+ // `maxRedirects: 0` ensures we never resend the file bytes and signed headers
418
+ // to a different host if the data-plane endpoint were to answer with a
419
+ // redirect.
420
+ try {
421
+ await axios.put(presign.url, createReadStream(props.file), {
422
+ headers: {
423
+ ...presign.headers,
424
+ 'Content-Length': fileSize,
425
+ },
426
+ maxBodyLength: Infinity,
427
+ maxContentLength: Infinity,
428
+ maxRedirects: 0,
429
+ });
430
+ }
431
+ catch (err) {
432
+ // The upload targets the S3 data plane, whose error bodies are XML rather
433
+ // than the JSON `{ message }` the console returns, so surface the status
434
+ // (and axios message) rather than leaking a raw error. Never include the
435
+ // presigned URL, which carries the signature.
436
+ if (isAxiosError(err)) {
437
+ const status = err.response?.status;
438
+ throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId}${status !== undefined ? ` (HTTP ${status})` : ''}: ${err.message}`);
439
+ }
440
+ throw err;
441
+ }
442
+ log.info(`File "${props.file}" uploaded to "${key}" in bucket "${bucket}" on branch ${branchId}`);
443
+ };
331
444
  const deleteObject = async (props) => {
332
445
  const branchId = await branchIdFromProps(props);
333
446
  const { bucket, rest } = splitBucketTarget(props.target);
@@ -7,7 +7,7 @@ import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.j
7
7
  import { fillSingleProject } from '../utils/enrichers.js';
8
8
  import { looksLikeBranchId } from '../utils/formats.js';
9
9
  import { autoPullEnvAfterPin } from './env.js';
10
- import { applyPolicyOnCreate } from './config.js';
10
+ import { applyPolicyOnCreate, createBranchFromPolicyOnCheckout, } from './config.js';
11
11
  import { handler as linkHandler } from './link.js';
12
12
  // The positional is optional: omitting it in an interactive terminal opens a
13
13
  // branch picker. In non-interactive contexts a missing branch is an error.
@@ -52,27 +52,30 @@ export const handler = async (props) => {
52
52
  // (--project-id flag > .neon file > single-project auto-detect); when
53
53
  // nothing resolves, fall back to an interactive `neonctl link`.
54
54
  const projectId = await resolveProjectId(props);
55
- const { branchId, created } = await resolveBranchId(props, projectId);
55
+ const { branchId, branchName, created, policyApplied } = await resolveBranchId(props, projectId);
56
56
  const orgId = await resolveOrgId(props, projectId);
57
- // `checkout` is a thin helper over `set-context`. It fully "heals" the
58
- // context file: it always (re)writes `projectId`, `branchId`, and `orgId`
59
- // (when the project has one) so a `.neon` that drifted or was missing fields
60
- // ends up complete and consistent after checkout.
57
+ // `checkout` is a thin helper over `link`. It fully "heals" the context file:
58
+ // it always (re)writes `projectId`, `branch`, and `orgId` (when the project
59
+ // has one) so a `.neon` that drifted or was missing fields ends up complete
60
+ // and consistent after checkout. The branch is stored as its name when known
61
+ // (see `link`'s `branch` field), matching what `link` writes.
61
62
  applyContext(props.contextFile, {
62
63
  projectId,
63
64
  ...(orgId ? { orgId } : {}),
64
- branchId,
65
+ branch: branchName,
65
66
  });
66
67
  log.info('Checked out branch %s on project %s%s. Updated %s.', branchId, projectId, orgId ? ` (org ${orgId})` : '', props.contextFile);
67
- // Only when checkout just *created* the branch do we apply the local neon.ts policy,
68
- // so a new branch comes up with the declared settings/infra immediately. Checking out an
69
- // existing branch never reconciles it that's an explicit `neonctl deploy` / `config
70
- // apply`. No neon.ts on disk nothing to apply.
71
- if (created) {
68
+ // When checkout *created* the branch and a neon.ts exists, the branch was created straight
69
+ // from the policy (evaluated as a new branch) so its settings/infra are already applied
70
+ // see `policyApplied`. The fallback below covers the case where the branch was created bare
71
+ // (e.g. a policy-driven create wasn't possible); `applyPolicyOnCreate` is a no-op when there
72
+ // is no neon.ts on disk. Checking out an existing branch never reconciles it.
73
+ if (created && !policyApplied) {
72
74
  await applyPolicyOnCreate({
73
75
  projectId,
74
76
  branchId,
75
77
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
78
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
76
79
  });
77
80
  }
78
81
  // Bundle `env pull` so the branch-first loop is just link + checkout: the branch you
@@ -94,26 +97,39 @@ const resolveBranchId = async (props, projectId) => {
94
97
  'or run interactively to pick one from a list.',
95
98
  });
96
99
  if (picked.kind === 'existing') {
97
- return { branchId: picked.branchId, created: false };
100
+ const existing = branches.find((b) => b.id === picked.branchId);
101
+ return {
102
+ branchId: picked.branchId,
103
+ branchName: existing?.name ?? picked.branchId,
104
+ created: false,
105
+ policyApplied: false,
106
+ };
98
107
  }
99
108
  // The user chose "create a new branch" from the picker.
100
- return {
101
- branchId: await createBranch(props.apiClient, projectId, picked.name, branches),
102
- created: true,
103
- };
109
+ return createCheckoutBranch(props, projectId, picked.name, branches);
104
110
  }
105
111
  const ref = props.id;
106
112
  // A `br-…` value is an id; match strictly by id and never offer to create.
107
113
  if (looksLikeBranchId(ref)) {
108
114
  const byId = branches.find((b) => b.id === ref);
109
115
  if (byId) {
110
- return { branchId: byId.id, created: false };
116
+ return {
117
+ branchId: byId.id,
118
+ branchName: byId.name ?? byId.id,
119
+ created: false,
120
+ policyApplied: false,
121
+ };
111
122
  }
112
123
  throw new Error(notFoundMessage(ref, branches));
113
124
  }
114
125
  const byName = branches.find((b) => b.name === ref);
115
126
  if (byName) {
116
- return { branchId: byName.id, created: false };
127
+ return {
128
+ branchId: byName.id,
129
+ branchName: byName.name ?? byName.id,
130
+ created: false,
131
+ policyApplied: false,
132
+ };
117
133
  }
118
134
  // Name not found: offer to create it interactively, mirroring `branch create`.
119
135
  if (isCi() || !process.stdout.isTTY) {
@@ -129,9 +145,35 @@ const resolveBranchId = async (props, projectId) => {
129
145
  if (!create) {
130
146
  throw new Error(`Aborted: branch "${ref}" was not found and not created.`);
131
147
  }
148
+ return createCheckoutBranch(props, projectId, ref, branches);
149
+ };
150
+ /**
151
+ * Create the branch to check out. When a `neon.ts` exists, route through the policy-driven
152
+ * create so the new branch comes up branched from the policy's `parent` and configured with
153
+ * its declared TTL / compute / services (evaluated as a *new* branch). Otherwise fall back to
154
+ * a bare branch off the default — the handler then applies the policy (a no-op with no
155
+ * `neon.ts`).
156
+ */
157
+ const createCheckoutBranch = async (props, projectId, name, branches) => {
158
+ const fromPolicy = await createBranchFromPolicyOnCheckout({
159
+ projectId,
160
+ branchName: name,
161
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
162
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
163
+ });
164
+ if (fromPolicy) {
165
+ return {
166
+ branchId: fromPolicy.branchId,
167
+ branchName: name,
168
+ created: true,
169
+ policyApplied: true,
170
+ };
171
+ }
132
172
  return {
133
- branchId: await createBranch(props.apiClient, projectId, ref, branches),
173
+ branchId: await createBranch(props.apiClient, projectId, name, branches),
174
+ branchName: name,
134
175
  created: true,
176
+ policyApplied: false,
135
177
  };
136
178
  };
137
179
  const notFoundMessage = (ref, branches) => `Branch ${ref} not found.\nAvailable branches: ${branches
@@ -202,6 +244,8 @@ const resolveProjectId = async (props) => {
202
244
  ...props,
203
245
  agent: false,
204
246
  yes: false,
247
+ clear: false,
248
+ checks: true,
205
249
  });
206
250
  const linked = readContextFile(props.contextFile);
207
251
  if (!linked.projectId) {
@@ -1,5 +1,5 @@
1
1
  import { resolveConfig } from '@neondatabase/config';
2
- import { apply, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
2
+ import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
3
3
  import { toNeonConfigView } from '../config_format.js';
4
4
  import { log } from '../log.js';
5
5
  import { loadEnvFileIntoProcess } from '../env_file.js';
@@ -7,6 +7,7 @@ import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
7
  import { bundleEntry } from '../utils/esbuild.js';
8
8
  import { zipBundle } from '../utils/zip.js';
9
9
  import { writer } from '../writer.js';
10
+ import { autoPullEnvAfterPin } from './env.js';
10
11
  /**
11
12
  * Bundle a function with neonctl's OWN bundler (the shared esbuild helper) so the
12
13
  * config-runtime never has to import esbuild itself. Injecting this keeps esbuild
@@ -16,6 +17,7 @@ import { writer } from '../writer.js';
16
17
  const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
17
18
  const INSPECT_FIELDS = ['project', 'branch', 'config'];
18
19
  const APPLIED_FIELDS = ['action', 'kind', 'identifier', 'details'];
20
+ const FUNCTION_FIELDS = ['slug', 'invocation_url'];
19
21
  const CONFLICT_FIELDS = [
20
22
  'identifier',
21
23
  'field',
@@ -47,6 +49,20 @@ export const applyFlags = {
47
49
  default: false,
48
50
  },
49
51
  };
52
+ /**
53
+ * `--env-pull` for `config apply` / `deploy` (shared so both expose the identical surface).
54
+ * After a successful apply, the branch's Neon env vars are written to a local `.env` — the
55
+ * same bundled convenience as `link` / `checkout`. On by default; `--no-env-pull` opts out.
56
+ */
57
+ export const envPullFlag = {
58
+ 'env-pull': {
59
+ describe: "Pull the branch's Neon env vars (DATABASE_URL, …) into a local .env after a " +
60
+ 'successful apply. On by default; use --no-env-pull to skip (e.g. when injecting ' +
61
+ 'env at runtime with `neon-env run` / `neon dev`).',
62
+ type: 'boolean',
63
+ default: true,
64
+ },
65
+ };
50
66
  export const command = 'config';
51
67
  export const describe = 'Manage a branch with a neon.ts policy';
52
68
  export const builder = (argv) => argv
@@ -85,6 +101,7 @@ export const builder = (argv) => argv
85
101
  },
86
102
  ...envFlag,
87
103
  ...applyFlags,
104
+ ...envPullFlag,
88
105
  }), (args) => applyCmd(args));
89
106
  export const handler = (args) => {
90
107
  return args;
@@ -107,6 +124,7 @@ export const status = async (props) => {
107
124
  projectId: props.projectId,
108
125
  branchId,
109
126
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
127
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
110
128
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
111
129
  });
112
130
  // The pulled `config` carries the branch's tuning inside a closure that JSON can't
@@ -141,9 +159,10 @@ export const planCmd = async (props) => {
141
159
  projectId: props.projectId,
142
160
  branchId,
143
161
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
162
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
144
163
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
145
164
  });
146
- reportPushResult(props, result, 'plan');
165
+ reportPushResult(props, result, 'plan', utilizedServices(config));
147
166
  };
148
167
  export const applyCmd = async (props) => {
149
168
  const config = await loadConfig(props);
@@ -152,21 +171,68 @@ export const applyCmd = async (props) => {
152
171
  projectId: props.projectId,
153
172
  branchId,
154
173
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
174
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
155
175
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
156
176
  ...(props.updateExisting ? { updateExisting: true } : {}),
157
177
  ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
158
178
  bundleFunction: neonctlBundler,
159
179
  });
160
- reportPushResult(props, result, 'apply');
180
+ reportPushResult(props, result, 'apply', utilizedServices(config));
181
+ // After a successful apply/deploy, write the branch's Neon env vars to a local .env —
182
+ // the same bundled convenience as `link` / `checkout`, so the branch is immediately
183
+ // usable for local dev. `--no-env-pull` opts out; a pull failure degrades to a warning
184
+ // (the apply already succeeded). See autoPullEnvAfterPin.
185
+ await autoPullEnvAfterPin({ ...props, envPull: props.envPull !== false });
161
186
  };
162
187
  /**
163
- * Render a {@link PushResult}. JSON/YAML output emits the raw result verbatim so it
164
- * can be piped; the human-readable path renders the actual changes (dropping noops)
165
- * and any blocking conflicts as tables, or a "nothing to do" line when both are empty.
188
+ * A static service toggle (`auth` / `dataApi` / `preview.aiGateway`) is "on" unless
189
+ * explicitly disabled: `true` / `{}` / `{ enabled: true }` enable it; `false` /
190
+ * `{ enabled: false }` / absent leave it off. Mirrors the runtime's `isServiceEnabled`
191
+ * (which isn't exported), kept tiny and pure so it can be read straight off the policy.
166
192
  */
167
- const reportPushResult = (props, result, mode) => {
193
+ const isToggleEnabled = (toggle) => {
194
+ if (toggle === undefined)
195
+ return false;
196
+ if (typeof toggle === 'boolean')
197
+ return toggle;
198
+ return toggle.enabled !== false;
199
+ };
200
+ /**
201
+ * Human-readable list of the services a `neon.ts` policy utilizes on the branch, shown under
202
+ * the plan/apply table. Postgres is always present (every branch has it); the rest are listed
203
+ * only when the policy declares them. This deliberately surfaces services that produce **no**
204
+ * plan step — notably the AI Gateway, which is always available and only needs a scoped branch
205
+ * credential (not a provisioning step) — so adding `preview.aiGateway` to a neon.ts isn't
206
+ * mistaken for being silently dropped. Service enablement is static top-level config (it never
207
+ * lives in the per-branch closure), so reading it straight off `config` is accurate.
208
+ */
209
+ const utilizedServices = (config) => {
210
+ const services = ['Postgres'];
211
+ if (isToggleEnabled(config.auth))
212
+ services.push('Neon Auth');
213
+ if (isToggleEnabled(config.dataApi))
214
+ services.push('Data API');
215
+ if (Object.keys(config.preview?.buckets ?? {}).length > 0) {
216
+ services.push('Object Storage');
217
+ }
218
+ if (Object.keys(config.preview?.functions ?? {}).length > 0) {
219
+ services.push('Functions');
220
+ }
221
+ if (isToggleEnabled(config.preview?.aiGateway))
222
+ services.push('AI Gateway');
223
+ return services;
224
+ };
225
+ /**
226
+ * Render a {@link PushResult}. JSON/YAML output emits the raw result (plus a `services`
227
+ * summary) verbatim so it can be piped; the human-readable path renders the actual changes
228
+ * (dropping noops) and any blocking conflicts as tables, or a "nothing to do" line when both
229
+ * are empty — and always closes with the list of services the policy utilizes so a service
230
+ * that produces no plan step (Postgres, or the credential-gated AI Gateway) isn't mistaken
231
+ * for being missing from the plan above.
232
+ */
233
+ const reportPushResult = (props, result, mode, services) => {
168
234
  if (props.output === 'json' || props.output === 'yaml') {
169
- writer(props).end(result, { fields: [] });
235
+ writer(props).end({ ...result, services }, { fields: [] });
170
236
  return;
171
237
  }
172
238
  const changes = result.applied
@@ -184,21 +250,46 @@ const reportPushResult = (props, result, mode) => {
184
250
  desired: stringify(conflict.desired),
185
251
  reason: conflict.reason,
186
252
  }));
187
- if (changes.length === 0 && conflicts.length === 0) {
188
- log.info(`No changes branch ${result.branchName} already matches the policy.`);
189
- return;
253
+ // Deployed functions carry their invocation URL in the change details — pull them into a
254
+ // dedicated table so users can see where to call each function without digging through the
255
+ // raw details blob. Keyed by slug so a function never shows twice.
256
+ const functionUrlBySlug = new Map();
257
+ for (const change of result.applied) {
258
+ if (change.action === 'noop')
259
+ continue;
260
+ const slug = change.details?.slug;
261
+ const invocationUrl = change.details?.invocationUrl;
262
+ if (typeof slug === 'string' && typeof invocationUrl === 'string') {
263
+ functionUrlBySlug.set(slug, invocationUrl);
264
+ }
190
265
  }
266
+ const functions = [...functionUrlBySlug].map(([slug, invocation_url]) => ({
267
+ slug,
268
+ invocation_url,
269
+ }));
191
270
  const out = writer(props);
271
+ const noChanges = changes.length === 0 && conflicts.length === 0;
192
272
  if (changes.length > 0) {
193
273
  out.write(changes, {
194
274
  fields: APPLIED_FIELDS,
195
275
  title: mode === 'plan' ? 'Planned changes' : 'Applied changes',
196
276
  });
197
277
  }
278
+ if (functions.length > 0) {
279
+ out.write(functions, {
280
+ fields: FUNCTION_FIELDS,
281
+ title: mode === 'plan' ? 'Function URLs (after apply)' : 'Function URLs',
282
+ });
283
+ }
198
284
  if (conflicts.length > 0) {
199
285
  out.write(conflicts, { fields: CONFLICT_FIELDS, title: 'Conflicts' });
200
286
  }
287
+ // Flush any tables, then append the summary so it reads directly below them.
201
288
  out.end();
289
+ if (noChanges) {
290
+ log.info(`No changes — branch ${result.branchName} already matches the policy.`);
291
+ }
292
+ out.text(`\nUtilized services: ${services.join(', ')}\n`);
202
293
  if (conflicts.length > 0) {
203
294
  log.info('Resolve the conflicts above, or re-run with --update-existing to override the current remote settings.');
204
295
  }
@@ -235,11 +326,16 @@ export const applyPolicyOnCreate = async (props) => {
235
326
  projectId: props.projectId,
236
327
  branchId: props.branchId,
237
328
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
329
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
238
330
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
239
331
  updateExisting: true,
240
332
  allowProtectedBranch: true,
241
333
  bundleFunction: neonctlBundler,
242
334
  });
335
+ logPolicyResult(result);
336
+ };
337
+ /** Log a one-line summary of what applying a `neon.ts` policy changed (or that nothing did). */
338
+ const logPolicyResult = (result) => {
243
339
  const changes = result.applied.filter((c) => c.action !== 'noop');
244
340
  if (changes.length === 0) {
245
341
  log.info('neon.ts applied — no changes were needed.');
@@ -247,3 +343,40 @@ export const applyPolicyOnCreate = async (props) => {
247
343
  }
248
344
  log.info('neon.ts applied — %d change%s: %s', changes.length, changes.length === 1 ? '' : 's', changes.map((c) => `${c.action} ${c.identifier}`).join(', '));
249
345
  };
346
+ /**
347
+ * Create a branch **from** the local `neon.ts` policy. Returns `null` when there is no
348
+ * `neon.ts` on the path from cwd up to the repo root, so `neonctl checkout` can fall back to a
349
+ * bare branch create.
350
+ *
351
+ * Unlike a bare create followed by {@link applyPolicyOnCreate}, this evaluates the policy for
352
+ * the **new** branch (`exists: false`): the runtime branches from the policy's `parent` and
353
+ * brings the branch up with its declared TTL / compute settings / services. That's what makes
354
+ * a policy keyed on `!branch.exists` (the common "only configure new branches" shape) take
355
+ * effect on the very first `checkout` — a bare create + `apply` always saw `exists: true` and
356
+ * skipped that block.
357
+ */
358
+ export const createBranchFromPolicyOnCheckout = async (props) => {
359
+ let config;
360
+ try {
361
+ ({ config } = await loadConfigFromFile({
362
+ ...(props.cwd ? { cwd: props.cwd } : {}),
363
+ }));
364
+ }
365
+ catch (err) {
366
+ const message = err instanceof Error ? err.message : String(err);
367
+ if (/Could not find a Neon config file/i.test(message))
368
+ return null;
369
+ throw err;
370
+ }
371
+ const { branchId, branchName, result } = await createBranchFromPolicy(config, {
372
+ projectId: props.projectId,
373
+ branchName: props.branchName,
374
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
375
+ ...(props.apiHost ? { apiHost: props.apiHost } : {}),
376
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
377
+ bundleFunction: neonctlBundler,
378
+ });
379
+ log.info('Created branch %s (%s) from neon.ts policy.', branchName, branchId);
380
+ logPolicyResult(result);
381
+ return { branchId };
382
+ };
@@ -1,5 +1,5 @@
1
1
  import { fillSingleProject } from '../utils/enrichers.js';
2
- import { applyCmd, applyFlags, envFlag } from './config.js';
2
+ import { applyCmd, applyFlags, envFlag, envPullFlag, } from './config.js';
3
3
  export const command = 'deploy';
4
4
  export const describe = 'Apply a neon.ts policy to a branch (alias for `config apply`)';
5
5
  export const builder = (argv) => argv
@@ -19,6 +19,7 @@ export const builder = (argv) => argv
19
19
  },
20
20
  ...envFlag,
21
21
  ...applyFlags,
22
+ ...envPullFlag,
22
23
  })
23
24
  .middleware(fillSingleProject)
24
25
  .strict();