neonctl 2.24.2 → 2.25.1

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);
@@ -52,16 +52,17 @@ 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, policyApplied } = 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
68
  // When checkout *created* the branch and a neon.ts exists, the branch was created straight
@@ -96,8 +97,10 @@ const resolveBranchId = async (props, projectId) => {
96
97
  'or run interactively to pick one from a list.',
97
98
  });
98
99
  if (picked.kind === 'existing') {
100
+ const existing = branches.find((b) => b.id === picked.branchId);
99
101
  return {
100
102
  branchId: picked.branchId,
103
+ branchName: existing?.name ?? picked.branchId,
101
104
  created: false,
102
105
  policyApplied: false,
103
106
  };
@@ -110,13 +113,23 @@ const resolveBranchId = async (props, projectId) => {
110
113
  if (looksLikeBranchId(ref)) {
111
114
  const byId = branches.find((b) => b.id === ref);
112
115
  if (byId) {
113
- return { branchId: byId.id, created: false, policyApplied: false };
116
+ return {
117
+ branchId: byId.id,
118
+ branchName: byId.name ?? byId.id,
119
+ created: false,
120
+ policyApplied: false,
121
+ };
114
122
  }
115
123
  throw new Error(notFoundMessage(ref, branches));
116
124
  }
117
125
  const byName = branches.find((b) => b.name === ref);
118
126
  if (byName) {
119
- return { branchId: byName.id, created: false, policyApplied: false };
127
+ return {
128
+ branchId: byName.id,
129
+ branchName: byName.name ?? byName.id,
130
+ created: false,
131
+ policyApplied: false,
132
+ };
120
133
  }
121
134
  // Name not found: offer to create it interactively, mirroring `branch create`.
122
135
  if (isCi() || !process.stdout.isTTY) {
@@ -151,12 +164,14 @@ const createCheckoutBranch = async (props, projectId, name, branches) => {
151
164
  if (fromPolicy) {
152
165
  return {
153
166
  branchId: fromPolicy.branchId,
167
+ branchName: name,
154
168
  created: true,
155
169
  policyApplied: true,
156
170
  };
157
171
  }
158
172
  return {
159
173
  branchId: await createBranch(props.apiClient, projectId, name, branches),
174
+ branchName: name,
160
175
  created: true,
161
176
  policyApplied: false,
162
177
  };
@@ -229,6 +244,8 @@ const resolveProjectId = async (props) => {
229
244
  ...props,
230
245
  agent: false,
231
246
  yes: false,
247
+ clear: false,
248
+ checks: true,
232
249
  });
233
250
  const linked = readContextFile(props.contextFile);
234
251
  if (!linked.projectId) {
@@ -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;
@@ -145,7 +162,7 @@ export const planCmd = async (props) => {
145
162
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
146
163
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
147
164
  });
148
- reportPushResult(props, result, 'plan');
165
+ reportPushResult(props, result, 'plan', utilizedServices(config));
149
166
  };
150
167
  export const applyCmd = async (props) => {
151
168
  const config = await loadConfig(props);
@@ -160,16 +177,62 @@ export const applyCmd = async (props) => {
160
177
  ...(props.allowProtected ? { allowProtectedBranch: true } : {}),
161
178
  bundleFunction: neonctlBundler,
162
179
  });
163
- 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 });
186
+ };
187
+ /**
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.
192
+ */
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;
164
224
  };
165
225
  /**
166
- * Render a {@link PushResult}. JSON/YAML output emits the raw result verbatim so it
167
- * can be piped; the human-readable path renders the actual changes (dropping noops)
168
- * and any blocking conflicts as tables, or a "nothing to do" line when both are empty.
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.
169
232
  */
170
- const reportPushResult = (props, result, mode) => {
233
+ const reportPushResult = (props, result, mode, services) => {
171
234
  if (props.output === 'json' || props.output === 'yaml') {
172
- writer(props).end(result, { fields: [] });
235
+ writer(props).end({ ...result, services }, { fields: [] });
173
236
  return;
174
237
  }
175
238
  const changes = result.applied
@@ -187,21 +250,46 @@ const reportPushResult = (props, result, mode) => {
187
250
  desired: stringify(conflict.desired),
188
251
  reason: conflict.reason,
189
252
  }));
190
- if (changes.length === 0 && conflicts.length === 0) {
191
- log.info(`No changes branch ${result.branchName} already matches the policy.`);
192
- 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
+ }
193
265
  }
266
+ const functions = [...functionUrlBySlug].map(([slug, invocation_url]) => ({
267
+ slug,
268
+ invocation_url,
269
+ }));
194
270
  const out = writer(props);
271
+ const noChanges = changes.length === 0 && conflicts.length === 0;
195
272
  if (changes.length > 0) {
196
273
  out.write(changes, {
197
274
  fields: APPLIED_FIELDS,
198
275
  title: mode === 'plan' ? 'Planned changes' : 'Applied changes',
199
276
  });
200
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
+ }
201
284
  if (conflicts.length > 0) {
202
285
  out.write(conflicts, { fields: CONFLICT_FIELDS, title: 'Conflicts' });
203
286
  }
287
+ // Flush any tables, then append the summary so it reads directly below them.
204
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`);
205
293
  if (conflicts.length > 0) {
206
294
  log.info('Resolve the conflicts above, or re-run with --update-existing to override the current remote settings.');
207
295
  }
@@ -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();
package/commands/dev.js CHANGED
@@ -116,7 +116,7 @@ const runFromConfig = async (props) => {
116
116
  * Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
117
117
  * base across them so search-mode functions don't all probe the same starting port.
118
118
  *
119
- * Each search-mode (no `dev.port`, non-portless) function gets a distinct base starting at
119
+ * Each search-mode (no `dev.port`) function gets a distinct base starting at
120
120
  * `searchBase`; the runtime still walks upward from its base, so an occupied base
121
121
  * self-resolves and this never fails — the offset just makes startup deterministic.
122
122
  */
@@ -124,7 +124,7 @@ const planFunctionsToUnits = (functions, neonEnv, searchBase) => {
124
124
  let searchOffset = 0;
125
125
  return functions.map((fn) => {
126
126
  const base = searchBase + searchOffset;
127
- if (!fn.portless && fn.port === undefined)
127
+ if (fn.port === undefined)
128
128
  searchOffset += 1;
129
129
  return plannedToUnit(fn, neonEnv, base);
130
130
  });
@@ -162,19 +162,14 @@ const portFromProps = (port) => {
162
162
  };
163
163
  /**
164
164
  * Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
165
- * - portless: portless assigns the port and injects PORT, which the runtime honors — so
166
- * we set no port env (`inherit`) and `dev.port` is ignored. Wrapped with
167
- * `portless <slug>` for a stable `slug.localhost` URL.
168
165
  * - explicit `dev.port`: bind exactly, fail if taken.
169
166
  * - no `dev.port`: search for a free port (base coordinated by the caller).
170
167
  * Per-function neon.ts env layers over the shared branch env.
171
168
  */
172
169
  const plannedToUnit = (fn, branchEnv, searchBase) => {
173
- const port = fn.portless
174
- ? { mode: 'inherit' }
175
- : fn.port !== undefined
176
- ? { mode: 'explicit', port: fn.port }
177
- : { mode: 'search', from: searchBase };
170
+ const port = fn.port !== undefined
171
+ ? { mode: 'explicit', port: fn.port }
172
+ : { mode: 'search', from: searchBase };
178
173
  const childEnv = buildChildEnv({ ...branchEnv, ...fn.env }, port);
179
174
  return {
180
175
  slug: fn.slug,
@@ -190,10 +185,8 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
190
185
  configKey: JSON.stringify({
191
186
  source: fn.source,
192
187
  port: fn.port ?? null,
193
- portless: fn.portless,
194
188
  env: fn.env,
195
189
  }),
196
- ...(fn.portless ? { portless: { slug: fn.slug } } : {}),
197
190
  };
198
191
  };
199
192
  /**
@@ -212,7 +205,6 @@ const buildChildEnv = (neonEnv, port) => {
212
205
  else if (port.mode === 'search') {
213
206
  env.NEON_DEV_PORT_BASE = String(port.from);
214
207
  }
215
- // 'inherit': set neither, so an injected PORT (portless) drives the runtime.
216
208
  return env;
217
209
  };
218
210
  const READY_PATTERN = /neon-dev:ready (\d+)/;
@@ -221,20 +213,16 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
221
213
  * its inputs for hot reload, and tear everything down cleanly on shutdown. Units are
222
214
  * independent — one crashing or failing to start does not stop the others (it is shown
223
215
  * as errored and recovered on the next edit). A single SIGINT/SIGTERM shuts all of them
224
- * down, tree-killing each child so no descendant (e.g. a portless-wrapped runtime) is
225
- * orphaned.
216
+ * down, tree-killing each child so no descendant it spawned is orphaned.
226
217
  *
227
218
  * In config mode, `reload` lets the supervisor watch `neon.ts` and reconcile the live set
228
219
  * of units when it changes: a newly-declared function is hot-added (its own child, watcher,
229
220
  * and port) and a removed one is torn down — all without disturbing the functions that
230
- * stayed the same. A function whose config (env/port/portless/source) changed is restarted
221
+ * stayed the same. A function whose config (env/port/source) changed is restarted
231
222
  * in place; siblings are untouched.
232
223
  */
233
224
  const runSupervisor = async (units, options = {}) => {
234
225
  const { reload, envNote } = options;
235
- if (hasPortlessUnit(units)) {
236
- assertPortlessAvailable();
237
- }
238
226
  const runtimePath = resolveRuntimePath();
239
227
  let shuttingDown = false;
240
228
  const running = units.map(makeRunningUnit);
@@ -380,7 +368,7 @@ const makeRunningUnit = (unit) => ({
380
368
  * Pure slug-keyed diff of the live units against the freshly-resolved desired set:
381
369
  * - a slug present now but not before → **add** (new child + watcher + port),
382
370
  * - a slug gone from neon.ts → **remove** (torn down),
383
- * - a slug whose config (source/port/portless/env) changed → **restart** in place,
371
+ * - a slug whose config (source/port/env) changed → **restart** in place,
384
372
  * - an unchanged slug → left out of the plan entirely (never touched).
385
373
  * Functions that stayed the same never die, so an edit that only adds a function is
386
374
  * non-disruptive. `desired === null` (neon.ts deleted) is treated as "no functions".
@@ -436,8 +424,6 @@ const reconcileOnce = async (running, replan, ops) => {
436
424
  }
437
425
  if (ops.isShuttingDown())
438
426
  return;
439
- if (hasPortlessUnit(desired ?? []))
440
- assertPortlessAvailable();
441
427
  const plan = diffUnits(running, desired);
442
428
  for (const r of plan.remove) {
443
429
  logUnit(r.unit, chalk.dim('removed from neon.ts, stopping…'));
@@ -479,50 +465,18 @@ const nextSearchBase = (running) => {
479
465
  }
480
466
  return max + 1;
481
467
  };
482
- const hasPortlessUnit = (units) => units.some((u) => u.portless !== undefined);
483
468
  /**
484
- * Spawn the child for a unit. A portless unit is wrapped as `portless <slug> node
485
- * <runtime> <bundle>`: portless assigns a port, injects it as PORT (which the runtime
486
- * honors), and exposes the server at `slug.localhost`. A plain unit runs the bundled
487
- * output directly under `node`.
469
+ * Spawn the child for a unit: the bundled output run directly under `node`.
488
470
  *
489
- * Spawned detached (own process group) so killTree can reap the whole group — important
490
- * for the portless case, where the tree is portless -> node runtime.
471
+ * Spawned detached (own process group) so killTree can reap the whole group.
491
472
  */
492
473
  const spawnChild = (unit, runtimePath, bundlePath) => {
493
- if (unit.portless) {
494
- return spawn('portless', [unit.portless.slug, process.execPath, runtimePath, bundlePath], {
495
- stdio: ['ignore', 'pipe', 'pipe'],
496
- env: unit.childEnv,
497
- detached: true,
498
- });
499
- }
500
474
  return spawn(process.execPath, [runtimePath, bundlePath], {
501
475
  stdio: ['ignore', 'pipe', 'pipe'],
502
476
  env: unit.childEnv,
503
477
  detached: true,
504
478
  });
505
479
  };
506
- /** Fail early with an actionable message if a portless unit is requested but the binary is missing. */
507
- const assertPortlessAvailable = () => {
508
- const result = spawnSyncCheck('portless');
509
- if (!result) {
510
- throw new Error('A function sets `dev.portless: true`, but the `portless` command was not ' +
511
- 'found on your PATH. Install it globally (e.g. `npm i -g portless`) or ' +
512
- 'remove `dev.portless` from the function in neon.ts.');
513
- }
514
- };
515
- const spawnSyncCheck = (bin) => {
516
- try {
517
- // Synchronous, no-side-effect probe: `which`/`where` resolves the binary.
518
- const probe = process.platform === 'win32' ? 'where' : 'which';
519
- const { status } = spawnSync(probe, [bin]);
520
- return status === 0;
521
- }
522
- catch {
523
- return false;
524
- }
525
- };
526
480
  const writeBundle = async (source, bundleDir) => {
527
481
  const files = await bundleEntry(source);
528
482
  mkdirSync(bundleDir, { recursive: true });
@@ -691,7 +645,7 @@ const startDirectoryWatcher = async (chokidar, source, restart) => {
691
645
  /**
692
646
  * Terminate a child and every descendant it spawned. The child is started `detached`, so
693
647
  * on POSIX it leads its own process group and a negative-PID signal reaps the group
694
- * (covering portless -> neonctl -> node). On Windows there are no POSIX groups, so we
648
+ * (covering the runtime and anything it spawned). On Windows there are no POSIX groups, so we
695
649
  * shell out to `taskkill /T` to kill the tree. Escalates SIGTERM -> SIGKILL after 2s.
696
650
  */
697
651
  const killTree = (child) => {
package/commands/env.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
2
  import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
3
+ import { existsSync } from 'node:fs';
3
4
  import { log } from '../log.js';
4
5
  import { resolveNeonEnvVars } from '../dev/env.js';
5
- import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
6
+ import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from '../env_file.js';
6
7
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
8
  export const command = 'env';
8
9
  export const describe = "Manage a branch's Neon env variables locally";
@@ -41,6 +42,12 @@ const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Objec
41
42
  export const pull = async (props) => {
42
43
  const cwd = props.cwd ?? process.cwd();
43
44
  const branchId = await branchIdFromProps(props);
45
+ // Resolve the target file first and layer its current contents under the resolver's env
46
+ // source. This lets `fetchEnv` reuse one-time secrets that are already on disk — Neon Auth
47
+ // keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
48
+ // API returns exactly once — instead of minting a fresh credential on every pull.
49
+ const targetPath = resolveEnvFilePath(cwd, props.file);
50
+ const existingEnv = existsSync(targetPath) ? readEnvFile(targetPath) : {};
44
51
  // Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
45
52
  // pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
46
53
  // `env pull` is an explicit action, so it should error rather than write nothing.
@@ -48,6 +55,7 @@ export const pull = async (props) => {
48
55
  cwd,
49
56
  projectId: props.projectId,
50
57
  branchId,
58
+ env: { ...process.env, ...existingEnv },
51
59
  ...(props.apiKey ? { apiKey: props.apiKey } : {}),
52
60
  ...(props.apiHost ? { apiHost: props.apiHost } : {}),
53
61
  ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
@@ -58,7 +66,6 @@ export const pull = async (props) => {
58
66
  'enabled Auth / Data API).');
59
67
  return { status: 'empty' };
60
68
  }
61
- const targetPath = resolveEnvFilePath(cwd, props.file);
62
69
  const { written } = mergeEnvFile(targetPath, neonVars);
63
70
  log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
64
71
  return { status: 'written', written, file: targetPath };