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/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
- printHumanSummary(props, {
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
- printHumanSummary(props, {
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 resolveDefaultBranchId(props, inputs.projectId);
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
- printHumanSummary(props, {
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
- printHumanSummary(props, {
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 resolveDefaultBranchId(props, action.projectId);
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
- printHumanSummary(props, {
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
- printHumanSummary(props, {
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.apiKey ? { apiKey: ctx.apiKey } : {}),
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
- * A missing branch context or any failure (no Neon account, no `.neon`, no network)
86
- * logs a warning and returns `{}` so the function still runs locally; only a
87
- * {@link DevEnvMismatchError} (policy declares a resource the branch lacks) is
88
- * re-thrown for the caller to surface.
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
- log.warning('Could not inject Neon env vars; the function will run without them: %s', err instanceof Error ? err.message : String(err));
102
- return {};
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.apiKey ? { apiKey: ctx.apiKey } : {}),
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.apiKey ? { apiKey: ctx.apiKey } : {}),
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.0",
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.0",
63
- "@neondatabase/config-runtime": "0.4.0",
64
- "@neondatabase/env": "0.3.0",
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
- export default JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf-8'));
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
- outfile: 'out.js',
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 out.js + out.js.map; an empty set
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, 'out.js');
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
- 'out.js': new Uint8Array(readFileSync(outfile)),
126
- 'out.js.map': new Uint8Array(readFileSync(`${outfile}.map`)),
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 (out.js + out.js.map) into the archive the Functions
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 });