neonctl 2.24.2 → 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.
- package/README.md +126 -41
- package/commands/auth.js +9 -0
- package/commands/bootstrap.js +603 -0
- package/commands/branches.js +6 -4
- package/commands/bucket.js +118 -5
- package/commands/checkout.js +25 -8
- package/commands/config.js +98 -10
- package/commands/deploy.js +2 -1
- package/commands/dev.js +11 -57
- package/commands/env.js +9 -2
- package/commands/functions.js +53 -5
- package/commands/index.js +2 -0
- package/commands/link.js +441 -108
- package/commands/projects.js +2 -2
- package/commands/set_context.js +5 -1
- package/config_format.js +8 -2
- package/context.js +33 -5
- package/dev/env.js +38 -0
- package/dev/functions.js +2 -4
- package/dev/runtime.js +2 -2
- package/index.js +1 -0
- package/package.json +5 -5
- package/storage_api.js +34 -0
- package/utils/bootstrap.js +243 -0
- package/utils/esbuild.js +11 -2
package/commands/bucket.js
CHANGED
|
@@ -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);
|
package/commands/checkout.js
CHANGED
|
@@ -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 `
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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) {
|
package/commands/config.js
CHANGED
|
@@ -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
|
|
167
|
-
* can be piped; the human-readable path renders the actual changes
|
|
168
|
-
* and any blocking conflicts as tables, or a "nothing to do" line when both
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
}
|
package/commands/deploy.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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.
|
|
174
|
-
? { mode: '
|
|
175
|
-
:
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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 };
|