neonctl 2.24.0 → 2.24.2
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 +51 -19
- package/commands/branches.js +14 -6
- package/commands/bucket.js +368 -0
- package/commands/checkout.js +64 -94
- package/commands/config.js +46 -1
- package/commands/dev.js +50 -14
- package/commands/env.js +57 -2
- package/commands/index.js +2 -0
- package/commands/link.js +72 -10
- package/dev/env.js +33 -14
- package/package.json +4 -4
- package/pkg.js +23 -1
- package/storage_api.js +114 -0
- package/utils/branch_picker.js +103 -0
- package/utils/esbuild.js +8 -5
- package/utils/zip.js +1 -1
package/commands/link.js
CHANGED
|
@@ -3,6 +3,8 @@ import prompts from 'prompts';
|
|
|
3
3
|
import { applyContext, readContextFile } from '../context.js';
|
|
4
4
|
import { isCi } from '../env.js';
|
|
5
5
|
import { log } from '../log.js';
|
|
6
|
+
import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
|
|
7
|
+
import { autoPullEnvAfterPin, renderAgentPullNote } from './env.js';
|
|
6
8
|
import { REGIONS } from './projects.js';
|
|
7
9
|
const PROJECTS_LIST_LIMIT = 100;
|
|
8
10
|
const CREATE_NEW_SENTINEL = '__create_new__';
|
|
@@ -40,6 +42,13 @@ export const builder = (argv) => argv.usage('$0 link [options]').options({
|
|
|
40
42
|
type: 'boolean',
|
|
41
43
|
default: false,
|
|
42
44
|
},
|
|
45
|
+
'env-pull': {
|
|
46
|
+
describe: "Pull the linked branch's Neon env vars (DATABASE_URL, …) into a local .env after " +
|
|
47
|
+
'linking. On by default; use --no-env-pull to skip (e.g. when injecting env at ' +
|
|
48
|
+
'runtime with `neon-env run` / `neon dev`).',
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
default: true,
|
|
51
|
+
},
|
|
43
52
|
});
|
|
44
53
|
export const handler = async (props) => {
|
|
45
54
|
if (props.agent) {
|
|
@@ -134,7 +143,7 @@ const runNonInteractive = async (props, inputs) => {
|
|
|
134
143
|
projectId: inputs.projectId,
|
|
135
144
|
branchId,
|
|
136
145
|
});
|
|
137
|
-
|
|
146
|
+
await finalizeHumanLink(props, {
|
|
138
147
|
contextFile: props.contextFile,
|
|
139
148
|
orgId,
|
|
140
149
|
projectId: inputs.projectId,
|
|
@@ -153,7 +162,7 @@ const runNonInteractive = async (props, inputs) => {
|
|
|
153
162
|
projectId: created.project.id,
|
|
154
163
|
branchId: created.branchId,
|
|
155
164
|
});
|
|
156
|
-
|
|
165
|
+
await finalizeHumanLink(props, {
|
|
157
166
|
contextFile: props.contextFile,
|
|
158
167
|
orgId,
|
|
159
168
|
projectId: created.project.id,
|
|
@@ -187,13 +196,13 @@ const runInteractive = async (props, inputs) => {
|
|
|
187
196
|
orgId = await promptOrgFromList(orgResolution.orgs);
|
|
188
197
|
}
|
|
189
198
|
if (inputs.projectId) {
|
|
190
|
-
const branchId = await
|
|
199
|
+
const branchId = await resolveInteractiveBranchId(props, inputs.projectId);
|
|
191
200
|
applyContext(props.contextFile, {
|
|
192
201
|
orgId,
|
|
193
202
|
projectId: inputs.projectId,
|
|
194
203
|
branchId,
|
|
195
204
|
});
|
|
196
|
-
|
|
205
|
+
await finalizeHumanLink(props, {
|
|
197
206
|
contextFile: props.contextFile,
|
|
198
207
|
orgId,
|
|
199
208
|
projectId: inputs.projectId,
|
|
@@ -213,7 +222,7 @@ const runInteractive = async (props, inputs) => {
|
|
|
213
222
|
projectId: created.project.id,
|
|
214
223
|
branchId: created.branchId,
|
|
215
224
|
});
|
|
216
|
-
|
|
225
|
+
await finalizeHumanLink(props, {
|
|
217
226
|
contextFile: props.contextFile,
|
|
218
227
|
orgId,
|
|
219
228
|
projectId: created.project.id,
|
|
@@ -228,13 +237,13 @@ const runInteractive = async (props, inputs) => {
|
|
|
228
237
|
const projects = await listAllProjects(props, orgId);
|
|
229
238
|
const action = await promptProjectChoice(projects, inputs.projectName);
|
|
230
239
|
if (action.type === 'existing') {
|
|
231
|
-
const branchId = await
|
|
240
|
+
const branchId = await resolveInteractiveBranchId(props, action.projectId);
|
|
232
241
|
applyContext(props.contextFile, {
|
|
233
242
|
orgId,
|
|
234
243
|
projectId: action.projectId,
|
|
235
244
|
branchId,
|
|
236
245
|
});
|
|
237
|
-
|
|
246
|
+
await finalizeHumanLink(props, {
|
|
238
247
|
contextFile: props.contextFile,
|
|
239
248
|
orgId,
|
|
240
249
|
projectId: action.projectId,
|
|
@@ -257,7 +266,7 @@ const runInteractive = async (props, inputs) => {
|
|
|
257
266
|
projectId: created.project.id,
|
|
258
267
|
branchId: created.branchId,
|
|
259
268
|
});
|
|
260
|
-
|
|
269
|
+
await finalizeHumanLink(props, {
|
|
261
270
|
contextFile: props.contextFile,
|
|
262
271
|
orgId,
|
|
263
272
|
projectId: created.project.id,
|
|
@@ -382,12 +391,18 @@ const runAgent = async (props, inputs) => {
|
|
|
382
391
|
if (projectId) {
|
|
383
392
|
const branchId = await resolveDefaultBranchId(props, projectId);
|
|
384
393
|
applyContext(props.contextFile, { orgId, projectId, branchId });
|
|
394
|
+
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
395
|
+
...props,
|
|
396
|
+
projectId,
|
|
397
|
+
branch: branchId,
|
|
398
|
+
envPull: props.envPull,
|
|
399
|
+
}));
|
|
385
400
|
emitAgent({
|
|
386
401
|
status: 'linked',
|
|
387
402
|
context_file: props.contextFile,
|
|
388
403
|
context: { orgId, projectId, branchId },
|
|
389
404
|
project: { id: projectId },
|
|
390
|
-
message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}
|
|
405
|
+
message: `Linked ${props.contextFile} to project ${projectId} (org ${orgId}) on branch ${branchId}.${pullNote}`,
|
|
391
406
|
});
|
|
392
407
|
return;
|
|
393
408
|
}
|
|
@@ -416,6 +431,12 @@ const runAgent = async (props, inputs) => {
|
|
|
416
431
|
projectId: created.project.id,
|
|
417
432
|
branchId: created.branchId,
|
|
418
433
|
});
|
|
434
|
+
const pullNote = renderAgentPullNote(await autoPullEnvAfterPin({
|
|
435
|
+
...props,
|
|
436
|
+
projectId: created.project.id,
|
|
437
|
+
branch: created.branchId,
|
|
438
|
+
envPull: props.envPull,
|
|
439
|
+
}));
|
|
419
440
|
emitAgent({
|
|
420
441
|
status: 'linked',
|
|
421
442
|
context_file: props.contextFile,
|
|
@@ -429,7 +450,7 @@ const runAgent = async (props, inputs) => {
|
|
|
429
450
|
name: created.project.name,
|
|
430
451
|
region_id: created.project.region_id,
|
|
431
452
|
},
|
|
432
|
-
message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}
|
|
453
|
+
message: `Created project ${created.project.id} ("${created.project.name ?? projectName}") in ${created.project.region_id ?? regionId} and linked ${props.contextFile}.${pullNote}`,
|
|
433
454
|
});
|
|
434
455
|
return;
|
|
435
456
|
}
|
|
@@ -591,6 +612,33 @@ const resolveDefaultBranchId = async (props, projectId) => {
|
|
|
591
612
|
}
|
|
592
613
|
return branch.id;
|
|
593
614
|
};
|
|
615
|
+
/**
|
|
616
|
+
* Resolve which branch to pin for an interactively-chosen project. When the project has a
|
|
617
|
+
* single branch there is nothing to choose, so we pin it silently. Otherwise we offer the
|
|
618
|
+
* shared branch picker (the same "+ Create a new branch…" + list as `neonctl checkout`),
|
|
619
|
+
* creating the branch when the user opts to. This makes `link` a full org → project →
|
|
620
|
+
* branch flow instead of always pinning the default branch.
|
|
621
|
+
*/
|
|
622
|
+
const resolveInteractiveBranchId = async (props, projectId) => {
|
|
623
|
+
const { data } = await props.apiClient.listProjectBranches({ projectId });
|
|
624
|
+
const branches = data.branches;
|
|
625
|
+
if (branches.length <= 1) {
|
|
626
|
+
const only = branches.find((b) => b.default) ?? branches[0];
|
|
627
|
+
if (!only) {
|
|
628
|
+
throw new Error(`Could not find a default branch for project ${projectId}.`);
|
|
629
|
+
}
|
|
630
|
+
return only.id;
|
|
631
|
+
}
|
|
632
|
+
const picked = await pickBranchInteractively(branches, {
|
|
633
|
+
message: 'Which branch would you like to link?',
|
|
634
|
+
nonInteractiveMessage: 'No branch could be selected without an interactive terminal. ' +
|
|
635
|
+
'Re-run `neonctl link` interactively, or `neonctl checkout <branch>` to pin one.',
|
|
636
|
+
});
|
|
637
|
+
if (picked.kind === 'existing') {
|
|
638
|
+
return picked.branchId;
|
|
639
|
+
}
|
|
640
|
+
return createBranch(props.apiClient, projectId, picked.name, branches);
|
|
641
|
+
};
|
|
594
642
|
const fetchRegions = async (props) => {
|
|
595
643
|
try {
|
|
596
644
|
const { data } = await props.apiClient.getActiveRegions();
|
|
@@ -648,6 +696,20 @@ const printHumanSummary = (_props, summary) => {
|
|
|
648
696
|
lines.push('');
|
|
649
697
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
650
698
|
};
|
|
699
|
+
/**
|
|
700
|
+
* Print the link summary, then run the bundled `env pull` so a human `link` ends with the
|
|
701
|
+
* branch's connection string already on disk — the branch-first loop is just link + checkout.
|
|
702
|
+
* `--no-env-pull` opts out (env pull's own status / skip hint is logged to stderr).
|
|
703
|
+
*/
|
|
704
|
+
const finalizeHumanLink = async (props, summary) => {
|
|
705
|
+
printHumanSummary(props, summary);
|
|
706
|
+
await autoPullEnvAfterPin({
|
|
707
|
+
...props,
|
|
708
|
+
projectId: summary.projectId,
|
|
709
|
+
branch: summary.branchId,
|
|
710
|
+
envPull: props.envPull,
|
|
711
|
+
});
|
|
712
|
+
};
|
|
651
713
|
const onPromptState = (state) => {
|
|
652
714
|
if (state.aborted) {
|
|
653
715
|
process.stdout.write('\x1B[?25h');
|
package/dev/env.js
CHANGED
|
@@ -2,6 +2,12 @@ import { loadConfigFromFile, } from '@neondatabase/config';
|
|
|
2
2
|
import { plan, pullConfig, } from '@neondatabase/config-runtime';
|
|
3
3
|
import { fetchEnv, toEntries } from '@neondatabase/env';
|
|
4
4
|
import { log } from '../log.js';
|
|
5
|
+
/** The API-targeting options every runtime call forwards from the context. */
|
|
6
|
+
const apiOptions = (ctx) => ({
|
|
7
|
+
...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
|
|
8
|
+
...(ctx.apiHost ? { apiHost: ctx.apiHost } : {}),
|
|
9
|
+
...(ctx.api ? { api: ctx.api } : {}),
|
|
10
|
+
});
|
|
5
11
|
/**
|
|
6
12
|
* Thrown when a `neon.ts` policy declares a branch-level resource (Neon Auth,
|
|
7
13
|
* Data API, a bucket, the AI Gateway) that the linked remote branch does not
|
|
@@ -70,8 +76,7 @@ export const resolveNeonEnvVars = async (ctx) => {
|
|
|
70
76
|
const pulled = await pullConfig({
|
|
71
77
|
projectId: ctx.projectId,
|
|
72
78
|
branchId: ctx.branchId,
|
|
73
|
-
...(ctx
|
|
74
|
-
...(ctx.api ? { api: ctx.api } : {}),
|
|
79
|
+
...apiOptions(ctx),
|
|
75
80
|
});
|
|
76
81
|
// `pulled.config` is already a `Config` (static auth/dataApi toggles + a branch
|
|
77
82
|
// tuning closure), so it feeds straight into fetchEnv — no wrapping needed.
|
|
@@ -82,24 +87,40 @@ export const resolveNeonEnvVars = async (ctx) => {
|
|
|
82
87
|
};
|
|
83
88
|
/**
|
|
84
89
|
* `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* {
|
|
88
|
-
*
|
|
90
|
+
*
|
|
91
|
+
* - Success → `{ vars }` (possibly just the always-present Postgres URLs).
|
|
92
|
+
* - No linked branch / project → `{ vars: {}, skipped }` with a "link a branch" hint; the
|
|
93
|
+
* function still runs locally, just without Neon env.
|
|
94
|
+
* - Any other failure (offline, transient API error) → `{ vars: {}, skipped }` naming the
|
|
95
|
+
* cause; again non-fatal.
|
|
96
|
+
* - {@link DevEnvMismatchError} (policy declares a secret-bearing service the branch lacks)
|
|
97
|
+
* is the one hard stop and is re-thrown for the caller to surface.
|
|
89
98
|
*/
|
|
90
99
|
export const resolveDevEnv = async (ctx) => {
|
|
91
100
|
try {
|
|
92
|
-
return await resolveNeonEnvVars(ctx);
|
|
101
|
+
return { vars: await resolveNeonEnvVars(ctx) };
|
|
93
102
|
}
|
|
94
103
|
catch (err) {
|
|
95
104
|
if (err instanceof DevEnvMismatchError)
|
|
96
105
|
throw err;
|
|
97
106
|
if (err instanceof MissingBranchContextError) {
|
|
98
107
|
log.debug('dev: %s; skipping env injection', err.message);
|
|
99
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
vars: {},
|
|
110
|
+
skipped: {
|
|
111
|
+
reason: 'no linked Neon branch — run `neonctl link`, then ' +
|
|
112
|
+
'`neonctl checkout <branch>`, to inject DATABASE_URL and friends',
|
|
113
|
+
},
|
|
114
|
+
};
|
|
100
115
|
}
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
117
|
+
log.debug('dev: env resolution failed: %s', detail);
|
|
118
|
+
return {
|
|
119
|
+
vars: {},
|
|
120
|
+
skipped: {
|
|
121
|
+
reason: `could not reach Neon (${detail}); running without Neon env`,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
103
124
|
}
|
|
104
125
|
};
|
|
105
126
|
/**
|
|
@@ -133,8 +154,7 @@ const assertPolicyMatchesBranch = async (config, ctx) => {
|
|
|
133
154
|
const result = await plan(config, {
|
|
134
155
|
projectId: ctx.projectId,
|
|
135
156
|
branchId: ctx.branchId,
|
|
136
|
-
...(ctx
|
|
137
|
-
...(ctx.api ? { api: ctx.api } : {}),
|
|
157
|
+
...apiOptions(ctx),
|
|
138
158
|
});
|
|
139
159
|
const missing = result.applied.filter(isMissingResource);
|
|
140
160
|
if (missing.length === 0)
|
|
@@ -158,8 +178,7 @@ const fetchAndProject = async (config, ctx) => {
|
|
|
158
178
|
const env = await fetchEnv(config, {
|
|
159
179
|
projectId: ctx.projectId,
|
|
160
180
|
branchId: ctx.branchId,
|
|
161
|
-
...(ctx
|
|
162
|
-
...(ctx.api ? { api: ctx.api } : {}),
|
|
181
|
+
...apiOptions(ctx),
|
|
163
182
|
});
|
|
164
183
|
return toEntries(env);
|
|
165
184
|
};
|
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.24.
|
|
8
|
+
"version": "2.24.2",
|
|
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.4.
|
|
63
|
-
"@neondatabase/config-runtime": "0.4.
|
|
64
|
-
"@neondatabase/env": "0.3.
|
|
62
|
+
"@neondatabase/config": "0.4.2",
|
|
63
|
+
"@neondatabase/config-runtime": "0.4.2",
|
|
64
|
+
"@neondatabase/env": "0.3.2",
|
|
65
65
|
"@segment/analytics-node": "1.3.0",
|
|
66
66
|
"axios": "1.7.2",
|
|
67
67
|
"axios-debug-log": "1.0.0",
|
package/pkg.js
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Load the CLI's package.json for version metadata. In the built CLI it sits right next to
|
|
6
|
+
* this module (the build copies it into `dist`); when running from source (tests, `tsx`) it
|
|
7
|
+
* does not, so we walk up to the nearest `package.json`. Both layouts resolve to the same
|
|
8
|
+
* file, keeping `pkg.version` correct everywhere without a test-only shim.
|
|
9
|
+
*/
|
|
10
|
+
const loadPkg = () => {
|
|
11
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
for (;;) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir) {
|
|
19
|
+
throw new Error('Could not locate package.json for version detection.');
|
|
20
|
+
}
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export default loadPkg();
|
package/storage_api.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Typed client helpers for the branch object-storage (bucket/object) API.
|
|
2
|
+
//
|
|
3
|
+
// These endpoints are part of the Neon object-storage surface (the "Buckets"
|
|
4
|
+
// tag in the public API). They are not yet exposed as typed methods on the
|
|
5
|
+
// published `@neondatabase/api-client` package, so the request/response types
|
|
6
|
+
// and the thin call helpers live here. They are implemented on top of the
|
|
7
|
+
// api-client's public `request()` method, which means they reuse the exact
|
|
8
|
+
// same authentication, base URL, headers and retry behaviour as every other
|
|
9
|
+
// neonctl command. When the generated client gains these methods, the call
|
|
10
|
+
// sites in `src/commands/bucket.ts` can switch over with no behavioural
|
|
11
|
+
// change.
|
|
12
|
+
const bucketsPath = (projectId, branchId) => `/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/buckets`;
|
|
13
|
+
const bucketPath = (projectId, branchId, bucketName) => `${bucketsPath(projectId, branchId)}/${encodeURIComponent(bucketName)}`;
|
|
14
|
+
/**
|
|
15
|
+
* Create a bucket on a branch.
|
|
16
|
+
*
|
|
17
|
+
* @request POST /projects/{project_id}/branches/{branch_id}/buckets
|
|
18
|
+
*/
|
|
19
|
+
export const createProjectBranchBucket = (apiClient, { projectId, branchId, name, accessLevel, }) => {
|
|
20
|
+
const body = { name };
|
|
21
|
+
// Omit access_level entirely so the server default (`private`) applies.
|
|
22
|
+
if (accessLevel !== undefined) {
|
|
23
|
+
body.access_level = accessLevel;
|
|
24
|
+
}
|
|
25
|
+
return apiClient.request({
|
|
26
|
+
path: bucketsPath(projectId, branchId),
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body,
|
|
29
|
+
format: 'json',
|
|
30
|
+
secure: true,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* List the buckets on a branch.
|
|
35
|
+
*
|
|
36
|
+
* @request GET /projects/{project_id}/branches/{branch_id}/buckets
|
|
37
|
+
*/
|
|
38
|
+
export const listProjectBranchBuckets = (apiClient, { projectId, branchId }) => apiClient.request({
|
|
39
|
+
path: bucketsPath(projectId, branchId),
|
|
40
|
+
method: 'GET',
|
|
41
|
+
format: 'json',
|
|
42
|
+
secure: true,
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Delete a bucket from a branch.
|
|
46
|
+
*
|
|
47
|
+
* @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}
|
|
48
|
+
*/
|
|
49
|
+
export const deleteProjectBranchBucket = (apiClient, { projectId, branchId, bucketName, }) => apiClient.request({
|
|
50
|
+
path: bucketPath(projectId, branchId, bucketName),
|
|
51
|
+
method: 'DELETE',
|
|
52
|
+
secure: true,
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* List objects (and collapsed folders) in a bucket on a branch.
|
|
56
|
+
*
|
|
57
|
+
* @request GET /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects
|
|
58
|
+
*/
|
|
59
|
+
export const listProjectBranchBucketObjects = (apiClient, { projectId, branchId, bucketName, ...query }) => apiClient.request({
|
|
60
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects`,
|
|
61
|
+
method: 'GET',
|
|
62
|
+
query,
|
|
63
|
+
format: 'json',
|
|
64
|
+
secure: true,
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Download an object's raw bytes from a bucket on a branch.
|
|
68
|
+
*
|
|
69
|
+
* The server returns the body as `application/octet-stream` with a
|
|
70
|
+
* `Content-Disposition: attachment` header; the helper requests the body as a
|
|
71
|
+
* stream (`responseType: 'stream'`), so `.data` is a Node `Readable` the caller
|
|
72
|
+
* can pipe straight to disk without buffering the whole object in memory. The
|
|
73
|
+
* response headers are returned alongside so the caller can derive a filename
|
|
74
|
+
* from `Content-Disposition`.
|
|
75
|
+
*
|
|
76
|
+
* The object key may contain `/`; it is percent-encoded into a single path
|
|
77
|
+
* segment so nested keys are routed to the `{object_key}` parameter.
|
|
78
|
+
*
|
|
79
|
+
* @request GET /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}/download
|
|
80
|
+
*/
|
|
81
|
+
export const getProjectBranchBucketObject = (apiClient, { projectId, branchId, bucketName, objectKey, }) => apiClient.request({
|
|
82
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}/download`,
|
|
83
|
+
method: 'GET',
|
|
84
|
+
format: 'stream',
|
|
85
|
+
secure: true,
|
|
86
|
+
});
|
|
87
|
+
/**
|
|
88
|
+
* Delete an object from a bucket on a branch.
|
|
89
|
+
*
|
|
90
|
+
* The object key may contain `/`; it is percent-encoded into a single path
|
|
91
|
+
* segment so nested keys are routed to the `{object_key}` parameter.
|
|
92
|
+
*
|
|
93
|
+
* @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}
|
|
94
|
+
*/
|
|
95
|
+
export const deleteProjectBranchBucketObject = (apiClient, { projectId, branchId, bucketName, objectKey, }) => apiClient.request({
|
|
96
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}`,
|
|
97
|
+
method: 'DELETE',
|
|
98
|
+
secure: true,
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* Delete every object under a key prefix (folder) in a bucket on a branch.
|
|
102
|
+
*
|
|
103
|
+
* `prefix` must be non-empty and end with `/`; every object on this branch
|
|
104
|
+
* whose key starts with the prefix is soft-deleted in a single call.
|
|
105
|
+
*
|
|
106
|
+
* @request DELETE /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects-by-prefix
|
|
107
|
+
*/
|
|
108
|
+
export const deleteProjectBranchBucketObjectsByPrefix = (apiClient, { projectId, branchId, bucketName, prefix, }) => apiClient.request({
|
|
109
|
+
path: `${bucketPath(projectId, branchId, bucketName)}/objects-by-prefix`,
|
|
110
|
+
method: 'DELETE',
|
|
111
|
+
query: { prefix },
|
|
112
|
+
format: 'json',
|
|
113
|
+
secure: true,
|
|
114
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { EndpointType } from '@neondatabase/api-client';
|
|
2
|
+
import prompts from 'prompts';
|
|
3
|
+
import { retryOnLock } from '../api.js';
|
|
4
|
+
import { log } from '../log.js';
|
|
5
|
+
import { isCi } from '../env.js';
|
|
6
|
+
/** Sentinel `value` for the "create a new branch" choice (no branch id can collide). */
|
|
7
|
+
const CREATE_BRANCH_CHOICE = Symbol('create-branch');
|
|
8
|
+
/**
|
|
9
|
+
* Render a branch's display name with the same word labels as `neonctl branch list`
|
|
10
|
+
* (`[default]`, `[protected]`) instead of symbols, so the picker reads clearly.
|
|
11
|
+
*/
|
|
12
|
+
const branchLabel = (branch) => {
|
|
13
|
+
const labels = [];
|
|
14
|
+
if (branch.default) {
|
|
15
|
+
labels.push('[default]');
|
|
16
|
+
}
|
|
17
|
+
if (branch.protected) {
|
|
18
|
+
labels.push('[protected]');
|
|
19
|
+
}
|
|
20
|
+
labels.push(branch.name);
|
|
21
|
+
return labels.join(' ');
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Prompt the user to pick a branch from `branches`, with a "+ Create a new branch…" option
|
|
25
|
+
* pinned to the top (mirroring the project/org pickers). The default selection is the
|
|
26
|
+
* project's default branch (the create option sits at index 0, so the default index is
|
|
27
|
+
* offset by one).
|
|
28
|
+
*
|
|
29
|
+
* Throws `opts.nonInteractiveMessage` when there is no TTY (or in CI): the caller knows the
|
|
30
|
+
* right guidance for its command, so the message is supplied rather than hard-coded here.
|
|
31
|
+
*/
|
|
32
|
+
export const pickBranchInteractively = async (branches, opts) => {
|
|
33
|
+
if (isCi() || !process.stdout.isTTY) {
|
|
34
|
+
throw new Error(opts.nonInteractiveMessage);
|
|
35
|
+
}
|
|
36
|
+
const defaultBranchIndex = branches.findIndex((b) => b.default);
|
|
37
|
+
const initial = defaultBranchIndex >= 0 ? defaultBranchIndex + 1 : 0;
|
|
38
|
+
const { choice } = await prompts({
|
|
39
|
+
type: 'select',
|
|
40
|
+
name: 'choice',
|
|
41
|
+
message: opts.message,
|
|
42
|
+
choices: [
|
|
43
|
+
{ title: '+ Create a new branch…', value: CREATE_BRANCH_CHOICE },
|
|
44
|
+
...branches.map((b) => ({
|
|
45
|
+
title: `${branchLabel(b)} (${b.id})`,
|
|
46
|
+
value: b.id,
|
|
47
|
+
})),
|
|
48
|
+
],
|
|
49
|
+
initial,
|
|
50
|
+
});
|
|
51
|
+
if (choice === undefined) {
|
|
52
|
+
throw new Error('Aborted: no branch selected.');
|
|
53
|
+
}
|
|
54
|
+
if (choice === CREATE_BRANCH_CHOICE) {
|
|
55
|
+
return { kind: 'create', name: await promptNewBranchName(branches) };
|
|
56
|
+
}
|
|
57
|
+
return { kind: 'existing', branchId: choice };
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Prompt for a new branch name, rejecting empty input and names already taken on the
|
|
61
|
+
* project (so we never silently select a different, pre-existing branch).
|
|
62
|
+
*/
|
|
63
|
+
export const promptNewBranchName = async (branches) => {
|
|
64
|
+
const existing = new Set(branches.map((b) => b.name));
|
|
65
|
+
const { name } = await prompts({
|
|
66
|
+
type: 'text',
|
|
67
|
+
name: 'name',
|
|
68
|
+
message: 'New branch name:',
|
|
69
|
+
validate: (value) => {
|
|
70
|
+
const trimmed = value.trim();
|
|
71
|
+
if (trimmed === '')
|
|
72
|
+
return 'Branch name cannot be empty.';
|
|
73
|
+
if (existing.has(trimmed))
|
|
74
|
+
return `A branch named "${trimmed}" already exists.`;
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
79
|
+
if (trimmed === '') {
|
|
80
|
+
throw new Error('Aborted: no branch name provided.');
|
|
81
|
+
}
|
|
82
|
+
return trimmed;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Create a branch with the same defaults as `neonctl branch create --name <name>`:
|
|
86
|
+
* branched from the project's default branch with a read-write compute endpoint. Returns
|
|
87
|
+
* the new branch id.
|
|
88
|
+
*/
|
|
89
|
+
export const createBranch = async (apiClient, projectId, name, branches) => {
|
|
90
|
+
const defaultBranch = branches.find((b) => b.default);
|
|
91
|
+
if (!defaultBranch) {
|
|
92
|
+
throw new Error('No default branch found');
|
|
93
|
+
}
|
|
94
|
+
const { data } = await retryOnLock(() => apiClient.createProjectBranch(projectId, {
|
|
95
|
+
branch: { name, parent_id: defaultBranch.id },
|
|
96
|
+
endpoints: [{ type: EndpointType.ReadWrite }],
|
|
97
|
+
}));
|
|
98
|
+
if (defaultBranch.protected) {
|
|
99
|
+
log.warning('The parent branch is protected; a unique role password has been generated for the new branch.');
|
|
100
|
+
}
|
|
101
|
+
log.info('Created branch %s (%s).', data.branch.name, data.branch.id);
|
|
102
|
+
return data.branch.id;
|
|
103
|
+
};
|
package/utils/esbuild.js
CHANGED
|
@@ -48,7 +48,10 @@ const bundleViaModule = async (source, loadEsbuild) => {
|
|
|
48
48
|
.build({
|
|
49
49
|
entryPoints: [source],
|
|
50
50
|
bundle: true,
|
|
51
|
-
|
|
51
|
+
// Emit `index.mjs` (not `out.js`): the Functions runtime imports the archive's entry
|
|
52
|
+
// by the conventional `index.{js,mjs}` name, and `.mjs` makes Node treat the ESM
|
|
53
|
+
// output as a module without needing a `package.json` type marker alongside it.
|
|
54
|
+
outfile: 'index.mjs',
|
|
52
55
|
write: false,
|
|
53
56
|
sourcemap: true,
|
|
54
57
|
minify: true,
|
|
@@ -61,7 +64,7 @@ const bundleViaModule = async (source, loadEsbuild) => {
|
|
|
61
64
|
throw new Error(`Failed to bundle function from ${source}. ${message(err)}`.trim());
|
|
62
65
|
});
|
|
63
66
|
const files = result.outputFiles ?? [];
|
|
64
|
-
// write:false with one entry always yields
|
|
67
|
+
// write:false with one entry always yields index.mjs + index.mjs.map; an empty set
|
|
65
68
|
// means the API contract changed under us — fail loud rather than ship an
|
|
66
69
|
// empty archive.
|
|
67
70
|
if (files.length === 0) {
|
|
@@ -105,7 +108,7 @@ const runEsbuild = (bin, args) => new Promise((resolve, reject) => {
|
|
|
105
108
|
const bundleViaBinary = async (source) => {
|
|
106
109
|
const bin = resolveEsbuild();
|
|
107
110
|
const outDir = mkdtempSync(join(tmpdir(), 'neon-fn-bundle-'));
|
|
108
|
-
const outfile = join(outDir, '
|
|
111
|
+
const outfile = join(outDir, 'index.mjs');
|
|
109
112
|
try {
|
|
110
113
|
const { code, stderr } = await runEsbuild(bin, [
|
|
111
114
|
source,
|
|
@@ -122,8 +125,8 @@ const bundleViaBinary = async (source) => {
|
|
|
122
125
|
throw new Error(`Failed to bundle function from ${source}. ${stderr.trim()}`.trim());
|
|
123
126
|
}
|
|
124
127
|
return {
|
|
125
|
-
'
|
|
126
|
-
'
|
|
128
|
+
'index.mjs': new Uint8Array(readFileSync(outfile)),
|
|
129
|
+
'index.mjs.map': new Uint8Array(readFileSync(`${outfile}.map`)),
|
|
127
130
|
};
|
|
128
131
|
}
|
|
129
132
|
finally {
|
package/utils/zip.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { zipSync } from 'fflate';
|
|
2
|
-
// Zip the esbuild output (
|
|
2
|
+
// Zip the esbuild output (index.mjs + index.mjs.map) into the archive the Functions
|
|
3
3
|
// deploy endpoint expects. Compression level 6 matches the previous bundler.
|
|
4
4
|
export const zipBundle = (entries) => zipSync(entries, { level: 6 });
|