neonctl 2.26.2 → 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/bucket.js +27 -3
- package/commands/config.js +22 -17
- package/commands/env.js +31 -1
- package/commands/functions.js +62 -19
- package/env_file.js +28 -15
- package/functions_api.js +3 -2
- package/package.json +4 -4
- package/test_utils/fixtures.js +1 -1
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/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/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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
8
|
import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
|
|
7
9
|
import { announceTargetBranch } from '../utils/branch_notice.js';
|
|
@@ -17,8 +19,12 @@ import { autoPullEnvAfterPin } from './env.js';
|
|
|
17
19
|
*/
|
|
18
20
|
const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source));
|
|
19
21
|
const INSPECT_FIELDS = ['project', 'branch', 'config'];
|
|
20
|
-
|
|
21
|
-
|
|
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'];
|
|
22
28
|
const CONFLICT_FIELDS = [
|
|
23
29
|
'identifier',
|
|
24
30
|
'field',
|
|
@@ -252,7 +258,6 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
252
258
|
action: change.action,
|
|
253
259
|
kind: change.kind,
|
|
254
260
|
identifier: change.identifier,
|
|
255
|
-
details: change.details ? JSON.stringify(change.details) : '',
|
|
256
261
|
}));
|
|
257
262
|
const conflicts = result.conflicts.map((conflict) => ({
|
|
258
263
|
identifier: conflict.identifier,
|
|
@@ -261,9 +266,9 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
261
266
|
desired: stringify(conflict.desired),
|
|
262
267
|
reason: conflict.reason,
|
|
263
268
|
}));
|
|
264
|
-
// Deployed functions carry their invocation URL in the change details —
|
|
265
|
-
//
|
|
266
|
-
//
|
|
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.
|
|
267
272
|
const functionUrlBySlug = new Map();
|
|
268
273
|
for (const change of result.applied) {
|
|
269
274
|
if (change.action === 'noop')
|
|
@@ -274,10 +279,6 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
274
279
|
functionUrlBySlug.set(slug, invocationUrl);
|
|
275
280
|
}
|
|
276
281
|
}
|
|
277
|
-
const functions = [...functionUrlBySlug].map(([slug, invocation_url]) => ({
|
|
278
|
-
slug,
|
|
279
|
-
invocation_url,
|
|
280
|
-
}));
|
|
281
282
|
const out = writer(props);
|
|
282
283
|
const noChanges = changes.length === 0 && conflicts.length === 0;
|
|
283
284
|
if (changes.length > 0) {
|
|
@@ -286,17 +287,21 @@ const reportPushResult = (props, result, mode, services) => {
|
|
|
286
287
|
title: mode === 'plan' ? 'Planned changes' : 'Applied changes',
|
|
287
288
|
});
|
|
288
289
|
}
|
|
289
|
-
if (functions.length > 0) {
|
|
290
|
-
out.write(functions, {
|
|
291
|
-
fields: FUNCTION_FIELDS,
|
|
292
|
-
title: mode === 'plan' ? 'Function URLs (after apply)' : 'Function URLs',
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
290
|
if (conflicts.length > 0) {
|
|
296
291
|
out.write(conflicts, { fields: CONFLICT_FIELDS, title: 'Conflicts' });
|
|
297
292
|
}
|
|
298
|
-
// Flush any tables, then append the summary so
|
|
293
|
+
// Flush any tables, then append the lists/summary so they read directly below them.
|
|
299
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
|
+
}
|
|
300
305
|
if (noChanges) {
|
|
301
306
|
log.info(`No changes — branch ${result.branchName} already matches the policy.`);
|
|
302
307
|
}
|
package/commands/env.js
CHANGED
|
@@ -44,6 +44,28 @@ export const builder = (argv) => argv
|
|
|
44
44
|
export const handler = (args) => args;
|
|
45
45
|
/** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
|
|
46
46
|
const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Object.values(group));
|
|
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
|
+
];
|
|
47
69
|
export const pull = async (props, opts = {}) => {
|
|
48
70
|
const cwd = props.cwd ?? process.cwd();
|
|
49
71
|
const branch = await resolveBranchRef(props);
|
|
@@ -75,8 +97,16 @@ export const pull = async (props, opts = {}) => {
|
|
|
75
97
|
'enabled Auth / Data API).');
|
|
76
98
|
return { status: 'empty' };
|
|
77
99
|
}
|
|
78
|
-
|
|
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
|
+
});
|
|
79
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
|
+
}
|
|
80
110
|
return { status: 'written', written, file: targetPath };
|
|
81
111
|
};
|
|
82
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/env_file.js
CHANGED
|
@@ -16,35 +16,47 @@ export const resolveEnvFilePath = (cwd, file) => {
|
|
|
16
16
|
* Merge `updates` into the dotenv content at `path`, preserving every other line
|
|
17
17
|
* (comments, blank lines, unrelated keys) and the file's existing order. Keys present in
|
|
18
18
|
* both are updated in place; keys only in `updates` are appended. A non-existent file is
|
|
19
|
-
* treated as empty.
|
|
19
|
+
* treated as empty. When `managedKeys` is given, any owned key on disk that is absent from
|
|
20
|
+
* `updates` is removed. Returns the keys written and the (managed) keys removed.
|
|
20
21
|
*/
|
|
21
|
-
export const mergeEnvFile = (path, updates) => {
|
|
22
|
+
export const mergeEnvFile = (path, updates, options = {}) => {
|
|
22
23
|
const original = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
23
|
-
const { content, written } = mergeEnvContent(original, updates);
|
|
24
|
+
const { content, written, removed } = mergeEnvContent(original, updates, options);
|
|
24
25
|
writeFileSync(path, content);
|
|
25
|
-
return { written };
|
|
26
|
+
return { written, removed };
|
|
26
27
|
};
|
|
27
28
|
/**
|
|
28
29
|
* Pure core of {@link mergeEnvFile}: takes the current file content and the updates, and
|
|
29
|
-
* returns the new content plus which keys were written. Kept side-effect-free so
|
|
30
|
-
* unit-tested without touching the filesystem.
|
|
30
|
+
* returns the new content plus which keys were written / removed. Kept side-effect-free so
|
|
31
|
+
* it can be unit-tested without touching the filesystem.
|
|
31
32
|
*/
|
|
32
|
-
export const mergeEnvContent = (original, updates) => {
|
|
33
|
+
export const mergeEnvContent = (original, updates, options = {}) => {
|
|
33
34
|
const keys = Object.keys(updates);
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// Owned keys the current pull did not produce: stale Neon-managed vars to prune. Anything
|
|
36
|
+
// not in `managedKeys` is always kept, so a user's own lines are never removed.
|
|
37
|
+
const stale = new Set([...(options.managedKeys ?? [])].filter((key) => !(key in updates)));
|
|
38
|
+
if (keys.length === 0 && stale.size === 0) {
|
|
39
|
+
return { content: original, written: [], removed: [] };
|
|
40
|
+
}
|
|
36
41
|
const remaining = new Set(keys);
|
|
42
|
+
const removed = [];
|
|
37
43
|
const lines = original === '' ? [] : original.split('\n');
|
|
38
|
-
//
|
|
39
|
-
// comments are preserved.
|
|
40
|
-
const updatedLines =
|
|
44
|
+
// Walk the file: drop stale owned lines, update existing keys in place (so their position
|
|
45
|
+
// and any surrounding comments are preserved), and pass everything else through untouched.
|
|
46
|
+
const updatedLines = [];
|
|
47
|
+
for (const line of lines) {
|
|
41
48
|
const key = parseKey(line);
|
|
49
|
+
if (key !== null && stale.has(key)) {
|
|
50
|
+
removed.push(key);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
42
53
|
if (key !== null && remaining.has(key)) {
|
|
43
54
|
remaining.delete(key);
|
|
44
|
-
|
|
55
|
+
updatedLines.push(formatLine(key, updates[key]));
|
|
56
|
+
continue;
|
|
45
57
|
}
|
|
46
|
-
|
|
47
|
-
}
|
|
58
|
+
updatedLines.push(line);
|
|
59
|
+
}
|
|
48
60
|
// Append keys that weren't already present, in the order they were given.
|
|
49
61
|
const appended = keys
|
|
50
62
|
.filter((key) => remaining.has(key))
|
|
@@ -55,6 +67,7 @@ export const mergeEnvContent = (original, updates) => {
|
|
|
55
67
|
// A dotenv file ends with a trailing newline.
|
|
56
68
|
content: content === '' ? '' : `${content}\n`,
|
|
57
69
|
written: keys,
|
|
70
|
+
removed,
|
|
58
71
|
};
|
|
59
72
|
};
|
|
60
73
|
/**
|
package/functions_api.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { ContentType } from '@neondatabase/api-client';
|
|
2
2
|
const functionsPath = (projectId, branchId) => `/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/functions`;
|
|
3
|
-
export const listFunctions = async (apiClient, projectId, branchId) => {
|
|
3
|
+
export const listFunctions = async (apiClient, projectId, branchId, { cursor, limit } = {}) => {
|
|
4
4
|
const { data } = await apiClient.request({
|
|
5
5
|
path: functionsPath(projectId, branchId),
|
|
6
6
|
method: 'GET',
|
|
7
|
+
query: { cursor, limit },
|
|
7
8
|
secure: true,
|
|
8
9
|
format: 'json',
|
|
9
10
|
});
|
|
10
|
-
return data.functions;
|
|
11
|
+
return { functions: data.functions ?? [], next: data.pagination?.next };
|
|
11
12
|
};
|
|
12
13
|
export const getFunction = async (apiClient, projectId, branchId, slug) => {
|
|
13
14
|
const { data } = await apiClient.request({
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "git+ssh://git@github.com/neondatabase/neonctl.git"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"version": "2.26.
|
|
8
|
+
"version": "2.26.3",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@hono/node-server": "2.0.4",
|
|
61
61
|
"@neondatabase/api-client": "2.7.1",
|
|
62
|
-
"@neondatabase/config": "0.7.
|
|
63
|
-
"@neondatabase/config-runtime": "0.7.
|
|
64
|
-
"@neondatabase/env": "0.5.
|
|
62
|
+
"@neondatabase/config": "0.7.2",
|
|
63
|
+
"@neondatabase/config-runtime": "0.7.2",
|
|
64
|
+
"@neondatabase/env": "0.5.2",
|
|
65
65
|
"@segment/analytics-node": "1.3.0",
|
|
66
66
|
"axios": "1.7.2",
|
|
67
67
|
"axios-debug-log": "1.0.0",
|
package/test_utils/fixtures.js
CHANGED
|
@@ -41,7 +41,7 @@ export const test = originalTest.extend({
|
|
|
41
41
|
'--api-host',
|
|
42
42
|
`http://localhost:${server.address().port}`,
|
|
43
43
|
'--output',
|
|
44
|
-
options.outputTable ? 'table' : 'yaml',
|
|
44
|
+
options.output ?? (options.outputTable ? 'table' : 'yaml'),
|
|
45
45
|
'--api-key',
|
|
46
46
|
'test-key',
|
|
47
47
|
'--no-analytics',
|