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.
- package/README.md +127 -42
- 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 +64 -20
- package/commands/config.js +144 -11
- package/commands/deploy.js +2 -1
- package/commands/dev.js +13 -57
- package/commands/env.js +10 -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 +47 -6
- 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/branch_picker.js +16 -1
- 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
|
@@ -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 `
|
|
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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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,
|
|
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) {
|
package/commands/config.js
CHANGED
|
@@ -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
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
};
|
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();
|