neonctl 2.26.1 → 2.26.3
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 +1 -1
- package/commands/auth.js +7 -0
- package/commands/bootstrap.js +18 -31
- package/commands/bucket.js +27 -3
- package/commands/checkout.js +9 -1
- package/commands/config.js +37 -21
- package/commands/dev.js +2 -3
- package/commands/env.js +44 -5
- package/commands/functions.js +62 -19
- package/commands/init.js +30 -4
- package/env_file.js +28 -15
- package/functions_api.js +3 -2
- package/package.json +5 -5
- package/test_utils/fixtures.js +1 -1
- package/utils/bootstrap.js +247 -126
- package/utils/branch_notice.js +22 -0
- package/utils/enrichers.js +39 -0
- package/utils/esbuild.js +4 -5
- package/utils/zip.js +2 -2
package/README.md
CHANGED
|
@@ -455,7 +455,7 @@ The target directory must be empty unless you pass `--force` (a lone `.git` is i
|
|
|
455
455
|
| [me](https://neon.com/docs/reference/cli-me) | | Show current user |
|
|
456
456
|
| [branches](https://neon.com/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-default`, `set-expiration`, `delete`, `get` | Manage branches |
|
|
457
457
|
| [databases](https://neon.com/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
|
|
458
|
-
|
|
|
458
|
+
| function | `deploy`, `list`, `get`, `delete` | Manage Neon Functions |
|
|
459
459
|
| [roles](https://neon.com/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
|
|
460
460
|
| [operations](https://neon.com/docs/reference/cli-operations) | `list` | Manage operations |
|
|
461
461
|
| [connection-string](https://neon.com/docs/reference/cli-connection-string) | | Get connection string |
|
package/commands/auth.js
CHANGED
|
@@ -110,6 +110,9 @@ export const ensureAuth = async (props) => {
|
|
|
110
110
|
// login. It uses an API key / stored credentials when present (harmless),
|
|
111
111
|
// otherwise it proceeds with no API client.
|
|
112
112
|
const isBootstrap = props._[0] === 'bootstrap';
|
|
113
|
+
// `init` manages its own auth flow (asks the user if they have an account,
|
|
114
|
+
// then triggers OAuth at the right time). Skip the global auth middleware.
|
|
115
|
+
const isInit = props._[0] === 'init';
|
|
113
116
|
// Use existing API key or handle auth command
|
|
114
117
|
if (props.apiKey || props._[0] === 'auth') {
|
|
115
118
|
if (props.apiKey) {
|
|
@@ -162,6 +165,10 @@ export const ensureAuth = async (props) => {
|
|
|
162
165
|
log.debug('bootstrap: no usable credentials; continuing without auth');
|
|
163
166
|
return;
|
|
164
167
|
}
|
|
168
|
+
if (isInit) {
|
|
169
|
+
log.debug('init: skipping global auth; init manages its own auth flow');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
165
172
|
// Start new auth flow if no valid token exists or refresh failed
|
|
166
173
|
const apiKey = await authFlow(props);
|
|
167
174
|
props.apiKey = apiKey;
|
package/commands/bootstrap.js
CHANGED
|
@@ -6,7 +6,7 @@ import prompts from 'prompts';
|
|
|
6
6
|
import which from 'which';
|
|
7
7
|
import { isCi } from '../env.js';
|
|
8
8
|
import { log } from '../log.js';
|
|
9
|
-
import { FALLBACK_TEMPLATES,
|
|
9
|
+
import { FALLBACK_TEMPLATES, downloadTemplate, fetchTemplates, findTemplate, templateIds, } from '../utils/bootstrap.js';
|
|
10
10
|
// The directory positional is optional: omitting it in an interactive terminal
|
|
11
11
|
// prompts for one. In a non-interactive context a missing directory is an error.
|
|
12
12
|
export const command = 'bootstrap [directory]';
|
|
@@ -101,18 +101,18 @@ const resolveTemplateList = async (props) => props.template && findTemplate(FALL
|
|
|
101
101
|
? FALLBACK_TEMPLATES
|
|
102
102
|
: fetchTemplates();
|
|
103
103
|
/**
|
|
104
|
-
* The picker label for a template: the title
|
|
105
|
-
* uses as a dim
|
|
106
|
-
* styled with chalk.dim only
|
|
107
|
-
* cyan/underline `prompts` paints over the focused row
|
|
108
|
-
*
|
|
109
|
-
*
|
|
104
|
+
* The picker label for a template: the title first, then the Neon services it
|
|
105
|
+
* uses as a dim, italic suffix, e.g. "Hono API … Postgres · Functions". The
|
|
106
|
+
* suffix is styled with chalk.dim (and italic) only — never a foreground color —
|
|
107
|
+
* so it survives the cyan/underline `prompts` paints over the focused row: dim
|
|
108
|
+
* and italic reset with their own SGRs, leaving the row's color and underline
|
|
109
|
+
* intact. Descriptions are intentionally omitted to keep the picker uncluttered.
|
|
110
110
|
*/
|
|
111
111
|
const formatTemplateTitle = (template) => {
|
|
112
112
|
if (!template.services || template.services.length === 0) {
|
|
113
113
|
return template.title;
|
|
114
114
|
}
|
|
115
|
-
return `${chalk.dim(
|
|
115
|
+
return `${template.title} ${chalk.dim.italic(template.services.join(' · '))}`;
|
|
116
116
|
};
|
|
117
117
|
const resolveSelectedTemplate = async (props, interactive, templates) => {
|
|
118
118
|
if (props.template) {
|
|
@@ -141,7 +141,6 @@ const resolveSelectedTemplate = async (props, interactive, templates) => {
|
|
|
141
141
|
message: 'Which template would you like to use?',
|
|
142
142
|
choices: templates.map((template) => ({
|
|
143
143
|
title: formatTemplateTitle(template),
|
|
144
|
-
description: template.description,
|
|
145
144
|
value: template.id,
|
|
146
145
|
})),
|
|
147
146
|
initial: 0,
|
|
@@ -207,25 +206,23 @@ const ensureTargetUsable = (dir, force) => {
|
|
|
207
206
|
};
|
|
208
207
|
const scaffold = async (template, targetDir) => {
|
|
209
208
|
log.info('Fetching template "%s" from GitHub…', template.id);
|
|
210
|
-
const
|
|
209
|
+
const files = await downloadTemplate(template);
|
|
211
210
|
mkdirSync(targetDir, { recursive: true });
|
|
212
|
-
log.info('Scaffolding %d files into %s…',
|
|
213
|
-
|
|
214
|
-
const dest = join(targetDir,
|
|
211
|
+
log.info('Scaffolding %d files into %s…', files.length, targetDir);
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const dest = join(targetDir, file.path);
|
|
215
214
|
mkdirSync(dirname(dest), { recursive: true });
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
writeSymlink(dest, target);
|
|
215
|
+
if (file.kind === 'symlink') {
|
|
216
|
+
writeSymlink(dest, file.target);
|
|
219
217
|
}
|
|
220
218
|
else {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (entry.executable) {
|
|
219
|
+
writeFileSync(dest, file.bytes);
|
|
220
|
+
if (file.executable) {
|
|
224
221
|
chmodSync(dest, 0o755);
|
|
225
222
|
}
|
|
226
223
|
}
|
|
227
|
-
}
|
|
228
|
-
return
|
|
224
|
+
}
|
|
225
|
+
return files.length;
|
|
229
226
|
};
|
|
230
227
|
const writeSymlink = (dest, target) => {
|
|
231
228
|
if (isSymlink(dest)) {
|
|
@@ -561,16 +558,6 @@ const displayDir = (targetDir) => {
|
|
|
561
558
|
}
|
|
562
559
|
return rel.startsWith('..') ? targetDir : rel;
|
|
563
560
|
};
|
|
564
|
-
const mapWithConcurrency = async (items, limit, fn) => {
|
|
565
|
-
const queue = [...items];
|
|
566
|
-
const worker = async () => {
|
|
567
|
-
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
568
|
-
await fn(next);
|
|
569
|
-
}
|
|
570
|
-
};
|
|
571
|
-
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
|
|
572
|
-
await Promise.all(workers);
|
|
573
|
-
};
|
|
574
561
|
const isSymlink = (path) => {
|
|
575
562
|
try {
|
|
576
563
|
return lstatSync(path).isSymbolicLink();
|
package/commands/bucket.js
CHANGED
|
@@ -95,7 +95,7 @@ export const builder = (argv) => argv
|
|
|
95
95
|
.command({
|
|
96
96
|
command: 'list <target>',
|
|
97
97
|
aliases: ['ls'],
|
|
98
|
-
describe: 'List objects in a bucket',
|
|
98
|
+
describe: 'List objects in a bucket. By default folders are collapsed (like "aws s3 ls"); pass --recursive for a flat listing of every key',
|
|
99
99
|
builder: (yargs) => yargs
|
|
100
100
|
.usage('$0 bucket object list <bucket>[/<prefix>] [options]')
|
|
101
101
|
.positional('target', {
|
|
@@ -105,8 +105,13 @@ export const builder = (argv) => argv
|
|
|
105
105
|
})
|
|
106
106
|
.options({
|
|
107
107
|
...scopeOptions,
|
|
108
|
+
recursive: {
|
|
109
|
+
describe: 'List every key flat, descending into nested folders (no delimiter). Mutually exclusive with --delimiter. Mirrors "aws s3 ls --recursive"',
|
|
110
|
+
type: 'boolean',
|
|
111
|
+
default: false,
|
|
112
|
+
},
|
|
108
113
|
delimiter: {
|
|
109
|
-
describe: 'Collapse keys sharing
|
|
114
|
+
describe: 'Collapse keys sharing this prefix separator into folders. Defaults to "/" (folder view); ignored when --recursive is set',
|
|
110
115
|
type: 'string',
|
|
111
116
|
},
|
|
112
117
|
cursor: {
|
|
@@ -223,7 +228,26 @@ const deleteBucket = async (props) => {
|
|
|
223
228
|
}
|
|
224
229
|
log.info(`Bucket "${props.name}" deleted from branch ${branchId}`);
|
|
225
230
|
};
|
|
231
|
+
// Resolve the delimiter to send to the backend, mirroring `aws s3 ls`:
|
|
232
|
+
// - default (neither flag): "/" so the listing is folder-collapsed;
|
|
233
|
+
// - --recursive: no delimiter, so every nested key is returned flat;
|
|
234
|
+
// - explicit --delimiter <x>: that value (an empty string lists flat too).
|
|
235
|
+
// `--recursive` together with an explicit `--delimiter` is nonsensical and is
|
|
236
|
+
// rejected client-side before any HTTP request is made.
|
|
237
|
+
export const resolveListDelimiter = (props) => {
|
|
238
|
+
if (props.recursive && props.delimiter !== undefined) {
|
|
239
|
+
throw new Error('--recursive and --delimiter cannot be used together. Use --recursive for a flat listing, or --delimiter to collapse on a separator.');
|
|
240
|
+
}
|
|
241
|
+
if (props.recursive) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
if (props.delimiter !== undefined) {
|
|
245
|
+
return props.delimiter;
|
|
246
|
+
}
|
|
247
|
+
return '/';
|
|
248
|
+
};
|
|
226
249
|
const listObjects = async (props) => {
|
|
250
|
+
const delimiter = resolveListDelimiter(props);
|
|
227
251
|
const branchId = await branchIdFromProps(props);
|
|
228
252
|
const { bucket, rest } = splitBucketTarget(props.target);
|
|
229
253
|
const { data } = await listProjectBranchBucketObjects(props.apiClient, {
|
|
@@ -231,7 +255,7 @@ const listObjects = async (props) => {
|
|
|
231
255
|
branchId,
|
|
232
256
|
bucketName: bucket,
|
|
233
257
|
prefix: rest === '' ? undefined : rest,
|
|
234
|
-
delimiter
|
|
258
|
+
delimiter,
|
|
235
259
|
cursor: props.cursor,
|
|
236
260
|
limit: props.limit,
|
|
237
261
|
});
|
package/commands/checkout.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAxiosError } from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
2
3
|
import prompts from 'prompts';
|
|
3
|
-
import { applyContext, readContextFile } from '../context.js';
|
|
4
|
+
import { applyContext, contextBranch, readContextFile } from '../context.js';
|
|
4
5
|
import { isCi } from '../env.js';
|
|
5
6
|
import { log } from '../log.js';
|
|
6
7
|
import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
|
|
@@ -47,6 +48,13 @@ export const builder = (argv) => argv
|
|
|
47
48
|
],
|
|
48
49
|
]);
|
|
49
50
|
export const handler = async (props) => {
|
|
51
|
+
// Show where the context is pinned *before* we switch it, so the user sees the move
|
|
52
|
+
// ("currently on X" → "checked out Y") and can catch a checkout they didn't mean to make.
|
|
53
|
+
// Read straight from `.neon` (a name, no API call); silent when nothing is pinned yet.
|
|
54
|
+
const previousBranch = contextBranch(readContextFile(props.contextFile));
|
|
55
|
+
if (previousBranch) {
|
|
56
|
+
log.info('%s Currently on branch %s', chalk.dim('→'), chalk.cyan.bold(previousBranch));
|
|
57
|
+
}
|
|
50
58
|
// Branch listing is project-scoped, so `projectId` is the only thing
|
|
51
59
|
// `checkout` actually needs. Resolve it through the standard chain
|
|
52
60
|
// (--project-id flag > .neon file > single-project auto-detect); when
|
package/commands/config.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { resolveConfig } from '@neondatabase/config';
|
|
2
3
|
import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
|
|
3
4
|
import { toNeonConfigView } from '../config_format.js';
|
|
4
5
|
import { log } from '../log.js';
|
|
6
|
+
import { isCi } from '../env.js';
|
|
5
7
|
import { loadEnvFileIntoProcess } from '../env_file.js';
|
|
6
|
-
import {
|
|
8
|
+
import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
|
|
9
|
+
import { announceTargetBranch } from '../utils/branch_notice.js';
|
|
7
10
|
import { bundleEntry } from '../utils/esbuild.js';
|
|
8
11
|
import { zipBundle } from '../utils/zip.js';
|
|
9
12
|
import { writer } from '../writer.js';
|
|
@@ -16,8 +19,12 @@ import { autoPullEnvAfterPin } from './env.js';
|
|
|
16
19
|
*/
|
|
17
20
|
const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
|
|
18
21
|
const INSPECT_FIELDS = ['project', 'branch', 'config'];
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
// Deliberately minimal: action/kind/identifier are short and fixed-ish, so the table can
|
|
23
|
+
// never overflow. Per-change `details` (a function's long invocationUrl in particular) are
|
|
24
|
+
// intentionally NOT a column — they used to be JSON-stringified into a cell and blew the
|
|
25
|
+
// table past 190 cols. Function URLs are printed below as a plain list (see reportPushResult),
|
|
26
|
+
// and the full details are still available via `--output json`.
|
|
27
|
+
const APPLIED_FIELDS = ['action', 'kind', 'identifier'];
|
|
21
28
|
const CONFLICT_FIELDS = [
|
|
22
29
|
'identifier',
|
|
23
30
|
'field',
|
|
@@ -119,7 +126,13 @@ const loadConfig = async (props) => {
|
|
|
119
126
|
return config;
|
|
120
127
|
};
|
|
121
128
|
export const status = async (props) => {
|
|
122
|
-
const
|
|
129
|
+
const branch = await resolveBranchRef(props);
|
|
130
|
+
// `--config-json` is a script-friendly mode that emits only JSON to stdout, so keep it
|
|
131
|
+
// pristine; the regular human view gets the "which branch am I inspecting" guardrail.
|
|
132
|
+
if (!props.configJson) {
|
|
133
|
+
announceTargetBranch(props, branch, 'Inspecting branch');
|
|
134
|
+
}
|
|
135
|
+
const branchId = branch.branchId;
|
|
123
136
|
const live = await inspect({
|
|
124
137
|
projectId: props.projectId,
|
|
125
138
|
branchId,
|
|
@@ -152,7 +165,9 @@ export const status = async (props) => {
|
|
|
152
165
|
};
|
|
153
166
|
export const planCmd = async (props) => {
|
|
154
167
|
const config = await loadConfig(props);
|
|
155
|
-
const
|
|
168
|
+
const branch = await resolveBranchRef(props);
|
|
169
|
+
announceTargetBranch(props, branch, 'Planning against branch');
|
|
170
|
+
const branchId = branch.branchId;
|
|
156
171
|
// `plan` is a dry run that never bundles, so its options don't accept (or need)
|
|
157
172
|
// an injected bundler — only `apply` does (it uses neonctlBundler).
|
|
158
173
|
const result = await plan(config, {
|
|
@@ -166,7 +181,9 @@ export const planCmd = async (props) => {
|
|
|
166
181
|
};
|
|
167
182
|
export const applyCmd = async (props) => {
|
|
168
183
|
const config = await loadConfig(props);
|
|
169
|
-
const
|
|
184
|
+
const branch = await resolveBranchRef(props);
|
|
185
|
+
announceTargetBranch(props, branch, 'Applying to branch');
|
|
186
|
+
const branchId = branch.branchId;
|
|
170
187
|
const result = await apply(config, {
|
|
171
188
|
projectId: props.projectId,
|
|
172
189
|
branchId,
|
|
@@ -241,7 +258,6 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
241
258
|
action: change.action,
|
|
242
259
|
kind: change.kind,
|
|
243
260
|
identifier: change.identifier,
|
|
244
|
-
details: change.details ? JSON.stringify(change.details) : '',
|
|
245
261
|
}));
|
|
246
262
|
const conflicts = result.conflicts.map((conflict) => ({
|
|
247
263
|
identifier: conflict.identifier,
|
|
@@ -250,9 +266,9 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
250
266
|
desired: stringify(conflict.desired),
|
|
251
267
|
reason: conflict.reason,
|
|
252
268
|
}));
|
|
253
|
-
// Deployed functions carry their invocation URL in the change details —
|
|
254
|
-
//
|
|
255
|
-
//
|
|
269
|
+
// Deployed functions carry their invocation URL in the change details — collect them so
|
|
270
|
+
// we can list where to call each function without digging through the raw details blob.
|
|
271
|
+
// Keyed by slug so a function never shows twice.
|
|
256
272
|
const functionUrlBySlug = new Map();
|
|
257
273
|
for (const change of result.applied) {
|
|
258
274
|
if (change.action === 'noop')
|
|
@@ -263,10 +279,6 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
263
279
|
functionUrlBySlug.set(slug, invocationUrl);
|
|
264
280
|
}
|
|
265
281
|
}
|
|
266
|
-
const functions = [...functionUrlBySlug].map(([slug, invocation_url]) => ({
|
|
267
|
-
slug,
|
|
268
|
-
invocation_url,
|
|
269
|
-
}));
|
|
270
282
|
const out = writer(props);
|
|
271
283
|
const noChanges = changes.length === 0 && conflicts.length === 0;
|
|
272
284
|
if (changes.length > 0) {
|
|
@@ -275,17 +287,21 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
275
287
|
title: mode === 'plan' ? 'Planned changes' : 'Applied changes',
|
|
276
288
|
});
|
|
277
289
|
}
|
|
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
|
-
}
|
|
284
290
|
if (conflicts.length > 0) {
|
|
285
291
|
out.write(conflicts, { fields: CONFLICT_FIELDS, title: 'Conflicts' });
|
|
286
292
|
}
|
|
287
|
-
// Flush any tables, then append the summary so
|
|
293
|
+
// Flush any tables, then append the lists/summary so they read directly below them.
|
|
288
294
|
out.end();
|
|
295
|
+
// Function URLs are a plain list rather than a table: an invocation URL can be 70+ chars,
|
|
296
|
+
// which makes any bordered table overflow and wrap awkwardly in a normal terminal. A list
|
|
297
|
+
// lets each URL reflow on its own line, and stays copy-pasteable.
|
|
298
|
+
if (functionUrlBySlug.size > 0) {
|
|
299
|
+
const heading = mode === 'plan' ? 'Function URLs (after apply)' : 'Function URLs';
|
|
300
|
+
out.text(`\n${isCi() ? heading : chalk.bold(heading)}\n`);
|
|
301
|
+
for (const [slug, invocationUrl] of functionUrlBySlug) {
|
|
302
|
+
out.text(` • ${slug}: ${invocationUrl}\n`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
289
305
|
if (noChanges) {
|
|
290
306
|
log.info(`No changes — branch ${result.branchName} already matches the policy.`);
|
|
291
307
|
}
|
package/commands/dev.js
CHANGED
|
@@ -480,9 +480,8 @@ const spawnChild = (unit, runtimePath, bundlePath) => {
|
|
|
480
480
|
const writeBundle = async (source, bundleDir) => {
|
|
481
481
|
const files = await bundleEntry(source);
|
|
482
482
|
mkdirSync(bundleDir, { recursive: true });
|
|
483
|
-
// bundleEntry emits `index.mjs` (
|
|
484
|
-
// it as ESM directly, so no `package.json` `"type": "module"` marker is needed
|
|
485
|
-
// points the sourcemap link at `index.mjs.map` for us.
|
|
483
|
+
// bundleEntry emits a single `index.mjs` (no source map). The `.mjs` extension makes Node
|
|
484
|
+
// load it as ESM directly, so no `package.json` `"type": "module"` marker is needed.
|
|
486
485
|
for (const [name, contents] of Object.entries(files)) {
|
|
487
486
|
writeFileSync(join(bundleDir, name), contents);
|
|
488
487
|
}
|
package/commands/env.js
CHANGED
|
@@ -4,7 +4,8 @@ import { existsSync } from 'node:fs';
|
|
|
4
4
|
import { log } from '../log.js';
|
|
5
5
|
import { resolveNeonEnvVars } from '../dev/env.js';
|
|
6
6
|
import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from '../env_file.js';
|
|
7
|
-
import {
|
|
7
|
+
import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
|
|
8
|
+
import { announceTargetBranch } from '../utils/branch_notice.js';
|
|
8
9
|
export const command = 'env';
|
|
9
10
|
export const describe = "Manage a branch's Neon env variables locally";
|
|
10
11
|
/**
|
|
@@ -33,15 +34,45 @@ export const builder = (argv) => argv
|
|
|
33
34
|
})
|
|
34
35
|
.example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
|
|
35
36
|
.example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), async (args) => {
|
|
36
|
-
|
|
37
|
+
// Explicit `env pull` announces the branch it's reading from up front so the user
|
|
38
|
+
// can catch "pulled env from the wrong branch" before it overwrites their .env. The
|
|
39
|
+
// bundled auto-pull (link / checkout / apply) stays quiet — those already report the
|
|
40
|
+
// branch they pinned/applied to.
|
|
41
|
+
await pull(args, { announce: true });
|
|
37
42
|
})
|
|
38
43
|
.demandCommand(1);
|
|
39
44
|
export const handler = (args) => args;
|
|
40
45
|
/** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
|
|
41
46
|
const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Object.values(group));
|
|
42
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The Neon env vars `env pull` *owns*, so it removes any that the branch no longer has when
|
|
49
|
+
* it reconciles the local `.env` (see {@link pull}). Scoped to the unambiguously Neon-named
|
|
50
|
+
* vars — the `NEON_*` aliases plus `DATABASE_URL[_UNPOOLED]` — so switching a working
|
|
51
|
+
* directory to a project/branch without Auth / the Data API drops the now-stale
|
|
52
|
+
* `NEON_AUTH_*` / `NEON_DATA_API_*` lines instead of leaving credentials for features that
|
|
53
|
+
* aren't enabled.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately **excludes** the storage / AI Gateway vars Neon projects onto third-party SDK
|
|
56
|
+
* names (`AWS_*`, `OPENAI_*`): those collide with credentials a user may set by hand, so
|
|
57
|
+
* `env pull` only ever writes them, never prunes them. (Their Neon-branded siblings —
|
|
58
|
+
* `NEON_STORAGE_*` / `NEON_AI_GATEWAY_*` — are owned and pruned.)
|
|
59
|
+
*/
|
|
60
|
+
const NEON_OWNED_ENV_KEYS = [
|
|
61
|
+
...Object.values(NEON_ENV_VAR_KEYS.postgres),
|
|
62
|
+
...Object.values(NEON_ENV_VAR_KEYS.auth),
|
|
63
|
+
...Object.values(NEON_ENV_VAR_KEYS.dataApi),
|
|
64
|
+
NEON_ENV_VAR_KEYS.storage.regionNeon,
|
|
65
|
+
NEON_ENV_VAR_KEYS.storage.forcePathStyle,
|
|
66
|
+
NEON_ENV_VAR_KEYS.aiGateway.neonToken,
|
|
67
|
+
NEON_ENV_VAR_KEYS.aiGateway.neonBaseUrl,
|
|
68
|
+
];
|
|
69
|
+
export const pull = async (props, opts = {}) => {
|
|
43
70
|
const cwd = props.cwd ?? process.cwd();
|
|
44
|
-
const
|
|
71
|
+
const branch = await resolveBranchRef(props);
|
|
72
|
+
if (opts.announce) {
|
|
73
|
+
announceTargetBranch(props, branch, 'Pulling env from branch');
|
|
74
|
+
}
|
|
75
|
+
const branchId = branch.branchId;
|
|
45
76
|
// Resolve the target file first and layer its current contents under the resolver's env
|
|
46
77
|
// source. This lets `fetchEnv` reuse one-time secrets that are already on disk — Neon Auth
|
|
47
78
|
// keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
|
|
@@ -66,8 +97,16 @@ export const pull = async (props) => {
|
|
|
66
97
|
'enabled Auth / Data API).');
|
|
67
98
|
return { status: 'empty' };
|
|
68
99
|
}
|
|
69
|
-
|
|
100
|
+
// Reconcile rather than blindly merge: write the branch's current Neon vars and prune any
|
|
101
|
+
// Neon-owned vars the branch no longer has (e.g. NEON_AUTH_* / NEON_DATA_API_* carried over
|
|
102
|
+
// from a previous project/branch). Non-Neon lines are always preserved.
|
|
103
|
+
const { written, removed } = mergeEnvFile(targetPath, neonVars, {
|
|
104
|
+
managedKeys: NEON_OWNED_ENV_KEYS,
|
|
105
|
+
});
|
|
70
106
|
log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
|
|
107
|
+
if (removed.length > 0) {
|
|
108
|
+
log.info('Removed %d stale Neon variable%s not enabled on this branch: %s', removed.length, removed.length === 1 ? '' : 's', removed.join(', '));
|
|
109
|
+
}
|
|
71
110
|
return { status: 'written', written, file: targetPath };
|
|
72
111
|
};
|
|
73
112
|
/**
|
package/commands/functions.js
CHANGED
|
@@ -14,6 +14,16 @@ const FUNCTION_FIELDS = [
|
|
|
14
14
|
'invocation_url',
|
|
15
15
|
'created_at',
|
|
16
16
|
];
|
|
17
|
+
const FUNCTIONS_LIST_LIMIT = 100;
|
|
18
|
+
// Table columns for `functions list`. `status` is a derived field (the
|
|
19
|
+
// table writer reads flat fields only): the current deployment's status.
|
|
20
|
+
const LIST_TABLE_FIELDS = [
|
|
21
|
+
'slug',
|
|
22
|
+
'name',
|
|
23
|
+
'status',
|
|
24
|
+
'invocation_url',
|
|
25
|
+
'created_at',
|
|
26
|
+
];
|
|
17
27
|
const DEPLOYMENT_FIELDS = [
|
|
18
28
|
'id',
|
|
19
29
|
'status',
|
|
@@ -45,14 +55,14 @@ const ENTRY_CANDIDATES = ['index.ts', 'index.mjs', 'index.js'];
|
|
|
45
55
|
// Overridable so tests can poll fast; defaults to 2s in real use.
|
|
46
56
|
const POLL_INTERVAL_MS = Number(process.env.NEON_FUNCTIONS_POLL_INTERVAL_MS) || 2000;
|
|
47
57
|
// Upper bound on --wait polling so the CLI never hangs (e.g. if our deployment
|
|
48
|
-
// never
|
|
58
|
+
// never shows up as current_deployment). Overridable so tests can time out fast;
|
|
49
59
|
// defaults to 10 minutes in real use.
|
|
50
60
|
const POLL_TIMEOUT_MS = Number(process.env.NEON_FUNCTIONS_POLL_TIMEOUT_MS) || 600000;
|
|
51
|
-
export const command = '
|
|
61
|
+
export const command = 'function';
|
|
52
62
|
export const describe = 'Manage Neon Functions';
|
|
53
|
-
export const aliases = ['
|
|
63
|
+
export const aliases = ['functions'];
|
|
54
64
|
export const builder = (argv) => argv
|
|
55
|
-
.usage('$0
|
|
65
|
+
.usage('$0 function <sub-command> [options]')
|
|
56
66
|
.options({
|
|
57
67
|
'project-id': {
|
|
58
68
|
describe: 'Project ID',
|
|
@@ -137,7 +147,7 @@ const parseEnv = (entries) => {
|
|
|
137
147
|
}
|
|
138
148
|
return JSON.stringify(map);
|
|
139
149
|
};
|
|
140
|
-
const statusHint = (slug, projectId, branchId) => `Check status with: neonctl
|
|
150
|
+
const statusHint = (slug, projectId, branchId) => `Check status with: neonctl function get ${slug} --project-id ${projectId} --branch ${branchId}`;
|
|
141
151
|
// Emit the resolved deployment together with the function's invocation_url, so the
|
|
142
152
|
// deploy output shows where the function is reachable (not just the deployment id).
|
|
143
153
|
const emitDeployResult = (props, deployment, fn) => {
|
|
@@ -165,7 +175,7 @@ const deploy = async (props) => {
|
|
|
165
175
|
props.runtime !== undefined;
|
|
166
176
|
if (!hasOption) {
|
|
167
177
|
throw new Error('Provide at least one option to deploy, e.g. --src or --env. ' +
|
|
168
|
-
'See: neonctl
|
|
178
|
+
'See: neonctl function deploy --help.');
|
|
169
179
|
}
|
|
170
180
|
// Cheap, offline validation first - fail before any network round-trip.
|
|
171
181
|
if (!SLUG_PATTERN.test(props.slug)) {
|
|
@@ -188,12 +198,12 @@ const deploy = async (props) => {
|
|
|
188
198
|
// Bundle before any network round-trip so a bundling failure fails fast.
|
|
189
199
|
const zip = zipBundle(await bundleEntry(source));
|
|
190
200
|
const branchId = await branchIdFromProps(props);
|
|
191
|
-
// Snapshot the
|
|
192
|
-
// afterward. A missing function (404) or no
|
|
201
|
+
// Snapshot the current version before deploy so we can detect the new one
|
|
202
|
+
// afterward. A missing function (404) or no deployment yet → undefined.
|
|
193
203
|
let before;
|
|
194
204
|
try {
|
|
195
205
|
const fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
|
|
196
|
-
before = fn.
|
|
206
|
+
before = fn.current_deployment?.id;
|
|
197
207
|
}
|
|
198
208
|
catch (err) {
|
|
199
209
|
if (!(isAxiosError(err) && err.response?.status === 404))
|
|
@@ -213,12 +223,12 @@ const deploy = async (props) => {
|
|
|
213
223
|
};
|
|
214
224
|
process.once('SIGINT', onSignal);
|
|
215
225
|
process.once('SIGTERM', onSignal);
|
|
216
|
-
// Poll until a NEW
|
|
226
|
+
// Poll until a NEW version appears (id greater than the snapshot, or
|
|
217
227
|
// any version if there was none). --no-wait stops there; --wait stops at a
|
|
218
228
|
// terminal status. Bounded by POLL_TIMEOUT_MS so it never hangs.
|
|
219
229
|
let resolved;
|
|
220
230
|
// The function carries the invocation_url; keep the whole record (not just its
|
|
221
|
-
//
|
|
231
|
+
// current_deployment) so we can surface that URL on success.
|
|
222
232
|
let resolvedFn;
|
|
223
233
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
224
234
|
try {
|
|
@@ -237,7 +247,7 @@ const deploy = async (props) => {
|
|
|
237
247
|
continue;
|
|
238
248
|
throw err;
|
|
239
249
|
}
|
|
240
|
-
const dep = fn.
|
|
250
|
+
const dep = fn.current_deployment;
|
|
241
251
|
const isNew = dep !== undefined && (before === undefined || dep.id > before);
|
|
242
252
|
if (isNew && dep) {
|
|
243
253
|
resolved = dep;
|
|
@@ -290,12 +300,31 @@ const get = async (props) => {
|
|
|
290
300
|
fields: FUNCTION_FIELDS,
|
|
291
301
|
title: 'function',
|
|
292
302
|
});
|
|
293
|
-
|
|
294
|
-
|
|
303
|
+
const current = fn.current_deployment;
|
|
304
|
+
const active = fn.active_deployment;
|
|
305
|
+
if (current && active && current.id === active.id) {
|
|
306
|
+
out.write(current, {
|
|
295
307
|
fields: DEPLOYMENT_FIELDS,
|
|
296
|
-
title: 'active
|
|
308
|
+
title: 'deployment (current, active)',
|
|
297
309
|
});
|
|
298
|
-
writeDeploymentErrorSection(out,
|
|
310
|
+
writeDeploymentErrorSection(out, current);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
if (current) {
|
|
314
|
+
out.write(current, {
|
|
315
|
+
fields: DEPLOYMENT_FIELDS,
|
|
316
|
+
title: 'current deployment',
|
|
317
|
+
});
|
|
318
|
+
// The failure reason is shown only for the current deployment;
|
|
319
|
+
// the active one completed successfully by definition.
|
|
320
|
+
writeDeploymentErrorSection(out, current);
|
|
321
|
+
}
|
|
322
|
+
if (active) {
|
|
323
|
+
out.write(active, {
|
|
324
|
+
fields: DEPLOYMENT_FIELDS,
|
|
325
|
+
title: 'active deployment',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
299
328
|
}
|
|
300
329
|
if (props.listEnvVariables) {
|
|
301
330
|
out.write((fn.active_deployment?.environment ?? []).map((name) => ({ name })), {
|
|
@@ -321,13 +350,27 @@ const deleteFn = async (props) => {
|
|
|
321
350
|
};
|
|
322
351
|
const list = async (props) => {
|
|
323
352
|
const branchId = await branchIdFromProps(props);
|
|
324
|
-
const functions =
|
|
353
|
+
const functions = [];
|
|
354
|
+
let cursor;
|
|
355
|
+
for (;;) {
|
|
356
|
+
const page = await listFunctions(props.apiClient, props.projectId, branchId, { cursor, limit: FUNCTIONS_LIST_LIMIT });
|
|
357
|
+
functions.push(...page.functions);
|
|
358
|
+
log.debug('Got %d functions, next cursor: %s', page.functions.length, page.next);
|
|
359
|
+
// A server echoing the same cursor would loop forever; treat it as
|
|
360
|
+
// the end of the list.
|
|
361
|
+
if (!page.next || page.next === cursor)
|
|
362
|
+
break;
|
|
363
|
+
cursor = page.next;
|
|
364
|
+
}
|
|
325
365
|
if (props.output === 'json' || props.output === 'yaml') {
|
|
326
366
|
writer(props).end(functions, { fields: FUNCTION_FIELDS });
|
|
327
367
|
return;
|
|
328
368
|
}
|
|
329
|
-
writer(props).end(functions
|
|
330
|
-
|
|
369
|
+
writer(props).end(functions.map((fn) => ({
|
|
370
|
+
...fn,
|
|
371
|
+
status: fn.current_deployment?.status ?? '',
|
|
372
|
+
})), {
|
|
373
|
+
fields: LIST_TABLE_FIELDS,
|
|
331
374
|
emptyMessage: 'No functions found on this branch.',
|
|
332
375
|
});
|
|
333
376
|
};
|
package/commands/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { interactiveInit, orchestrate } from 'neon-init';
|
|
1
|
+
import { detectAgent, enrichResponse, interactiveInit, orchestrate, routeDataStep, } from 'neon-init';
|
|
2
2
|
import { sendError } from '../analytics.js';
|
|
3
3
|
import { log } from '../log.js';
|
|
4
4
|
export const command = 'init';
|
|
@@ -11,6 +11,10 @@ export const builder = (yargs) => yargs
|
|
|
11
11
|
alias: 'a',
|
|
12
12
|
type: 'string',
|
|
13
13
|
describe: 'Agent to configure (cursor, copilot, claude, etc.).',
|
|
14
|
+
})
|
|
15
|
+
.option('data', {
|
|
16
|
+
type: 'string',
|
|
17
|
+
describe: 'JSON object with a "step" field to route to a specific phase and phase-specific options.',
|
|
14
18
|
})
|
|
15
19
|
.option('skip-neon-auth', {
|
|
16
20
|
type: 'boolean',
|
|
@@ -30,14 +34,36 @@ export const builder = (yargs) => yargs
|
|
|
30
34
|
.strict(false);
|
|
31
35
|
export const handler = async (argv) => {
|
|
32
36
|
try {
|
|
33
|
-
if
|
|
37
|
+
// Auto-detect agent from environment if --agent not explicitly provided.
|
|
38
|
+
// For IDE-based detection (Cursor, VS Code, Windsurf), require non-TTY stdin
|
|
39
|
+
// to distinguish "agent spawned this" from "human typed this in terminal".
|
|
40
|
+
const agent = argv.agent || (!process.stdin.isTTY ? detectAgent() : null) || undefined;
|
|
41
|
+
const isAgentMode = agent !== undefined;
|
|
42
|
+
// --data with a "step" field routes to the appropriate phase
|
|
43
|
+
if (argv.data && isAgentMode) {
|
|
44
|
+
let data;
|
|
45
|
+
try {
|
|
46
|
+
data = JSON.parse(argv.data);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
log.error('Invalid JSON in --data flag. Expected a JSON object.');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (typeof data.step === 'string') {
|
|
54
|
+
const result = await routeDataStep(data, agent);
|
|
55
|
+
log.info(JSON.stringify(enrichResponse(result), null, 2));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (isAgentMode) {
|
|
34
60
|
const result = await orchestrate({
|
|
35
|
-
agent
|
|
61
|
+
agent,
|
|
36
62
|
skipNeonAuth: argv.skipNeonAuth,
|
|
37
63
|
skipMigrations: argv.skipMigrations,
|
|
38
64
|
preview: argv.preview,
|
|
39
65
|
});
|
|
40
|
-
log.info(JSON.stringify(result, null, 2));
|
|
66
|
+
log.info(JSON.stringify(enrichResponse(result), null, 2));
|
|
41
67
|
}
|
|
42
68
|
else {
|
|
43
69
|
await interactiveInit({ preview: argv.preview });
|