neonctl 2.23.1 → 2.24.1

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 CHANGED
@@ -140,6 +140,8 @@ The Neon CLI supports autocompletion, which you can configure in a few easy step
140
140
 
141
141
  `neonctl link` is a Vercel-style command that binds the current directory to a Neon project. It picks (or creates) an organization, picks (or creates) a project, resolves the project's default branch, and writes a `.neon` file with `{ "orgId", "projectId", "branchId" }`. Subsequent commands run in this directory (or any sub-directory) automatically pick up that context.
142
142
 
143
+ Once the branch is pinned, `link` also runs [`env pull`](#env-pull) for you so the branch's Neon env vars (`DATABASE_URL`, …) land in a local `.env` and the project is immediately ready for local dev. Pass `--no-env-pull` to skip it (for example when injecting env at runtime with `neon-env run` or `neonctl dev`).
144
+
143
145
  There are three modes:
144
146
 
145
147
  **Interactive (default)** — guided prompts for humans:
@@ -147,7 +149,7 @@ There are three modes:
147
149
  ```bash
148
150
  $ neonctl link
149
151
  ? Which organization would you like to link? › Personal Org (org-abc123)
150
- ? Which project would you like to link? › + Create new project
152
+ ? Which project would you like to link? › Create new project
151
153
  ? Name for the new project: › my-app
152
154
  ? Which region should the new project run in? › AWS US East (Ohio) (aws-us-east-2)
153
155
  Created project polished-snowflake-12345678 ("my-app") in aws-us-east-2.
@@ -157,6 +159,16 @@ Linked .neon:
157
159
  branchId: br-main-branch-87654321
158
160
  ```
159
161
 
162
+ When you link an **existing** project that has more than one branch, `link` adds a final
163
+ step to pick which branch to pin — the same `+ Create a new branch…` + list selector used by
164
+ `neonctl checkout` (a single-branch project is pinned automatically, no prompt):
165
+
166
+ ```bash
167
+ ? Which organization would you like to link? › Personal Org (org-abc123)
168
+ ? Which project would you like to link? › my-app (polished-snowflake-12345678)
169
+ ? Which branch would you like to link? › ✱ main (br-main-branch-87654321)
170
+ ```
171
+
160
172
  **Non-interactive (flags or `--params` JSON)** — for scripts and CI:
161
173
 
162
174
  ```bash
@@ -265,29 +277,108 @@ $ cat .neon
265
277
  }
266
278
  ```
267
279
 
280
+ After pinning the branch, `checkout` also runs [`env pull`](#env-pull) by default, so the branch's Neon env vars are written to your local `.env` and you can start building right away — the branch-first loop is just `link` + `checkout`. Pass `--no-env-pull` to skip it (for example when env is injected at runtime via `neon-env run` / `neonctl dev`, or to keep secrets out of the working tree). A pull failure never undoes the checkout: the branch stays pinned and the failure is surfaced as a warning pointing you at `neonctl env pull` (or `neonctl deploy` if a `neon.ts`-declared service is missing).
281
+
282
+ ### env pull
283
+
284
+ `env pull` writes the linked branch's Neon environment variables into a local dotenv file: an existing `.env` if you have one, otherwise `.env.local` (override with `--file <path>`). Only Neon-managed keys (`DATABASE_URL`, `DATABASE_URL_UNPOOLED`, and the Neon Auth / Data API URLs when those services are enabled) are written; any other lines in the file are preserved. The branch comes from the closest `.neon` file, so no `--branch` is needed (pass `--branch <id|name>` to target another branch).
285
+
286
+ `link` and `checkout` invoke `env pull` automatically (see above), so you usually only run it by hand to refresh vars or to pull a different branch into a specific file:
287
+
288
+ ```bash
289
+ # Refresh the linked branch's vars in place
290
+ neonctl env pull
291
+
292
+ # Pull a specific branch into a specific file
293
+ neonctl env pull --branch preview --file .env.preview
294
+ ```
295
+
296
+ If you'd rather not keep env vars on disk, inject them at runtime instead with `neon-env run -- <your dev command>` (from `@neondatabase/env`) or `neonctl dev`, and pass `--no-env-pull` to `link` / `checkout`.
297
+
268
298
  **Where `.neon` lives**: `link` (and `set-context`) write `.neon` into the **current working directory** by default. If an existing `.neon` is found in any parent directory, that file is reused — so commands run from a sub-directory of a linked project still pick up the project's context. To pin the location explicitly, pass `--context-file <path>`.
269
299
 
270
300
  **`.gitignore` scaffolding**: when `.neon` is **created** for the first time, the CLI also makes sure a `.gitignore` sits alongside it listing `.neon`. If `.gitignore` doesn't exist it's created with a single `.neon` line; if it does exist, `.neon` is appended only when missing (no duplicates, your other entries are left alone). On subsequent updates to an existing `.neon`, `.gitignore` is left untouched — so if you deliberately un-ignore `.neon` (e.g. to commit shared context), the entry is not re-added on every command.
271
301
 
302
+ ## Config as code (`config` / `deploy`)
303
+
304
+ Describe a branch's desired state in a `neon.ts` policy and reconcile it from the CLI — the Neon equivalent of `terraform status` / `plan` / `apply`. The policy is a function of the branch it's being evaluated for; you switch on the branch (`name`, `isDefault`, …) and return the config you want (auth, Data API, compute settings, TTL, protection, and Preview features like Functions and buckets):
305
+
306
+ ```ts
307
+ // neon.ts
308
+ import { defineConfig } from '@neondatabase/config/v1';
309
+
310
+ export default defineConfig((branch) => {
311
+ if (branch.isDefault) {
312
+ return { protected: true, auth: {} };
313
+ }
314
+ return { parent: 'main', ttl: '7d' };
315
+ });
316
+ ```
317
+
318
+ Three sub-commands plus a top-level alias drive it:
319
+
320
+ ```bash
321
+ # Inspect the branch's live Neon state (read-only — never mutates)
322
+ neon config status
323
+
324
+ # Dry-run diff: show exactly what `apply` would change
325
+ neon config plan
326
+
327
+ # Reconcile the policy against the branch
328
+ neon config apply
329
+
330
+ # `neon deploy` is an alias for `neon config apply`
331
+ neon deploy
332
+ ```
333
+
334
+ **Project & branch resolution** follows the same chain as the rest of the CLI, each entry winning over the next:
335
+
336
+ 1. `--project-id <id>` flag
337
+ 2. `projectId` from the closest `.neon` file (found by walking up from the current directory — see "Where `.neon` lives" above)
338
+ 3. If still unresolved and the API key maps to exactly one project, that project is auto-detected
339
+
340
+ The branch is chosen with `--branch <id|name>`; without it the project's default branch is used. The policy itself is found by walking up from the current directory for a `neon.ts`, or pass `--config <path>` to point at one explicitly.
341
+
342
+ **Apply-only flags** (also available on `deploy`):
343
+
344
+ - `--update-existing` — auto-confirm overriding existing remote settings on the branch. Without it, drift on settings already present remotely (compute, TTL, `protected`) is reported as a **conflict** and `apply` makes no changes until you resolve it or pass this flag.
345
+ - `--allow-protected` — auto-confirm applying to a branch Neon marks as protected. Without it, `apply` refuses to touch a protected branch.
346
+
347
+ **Output**: `status` prints the project, branch, and reverse-engineered config; `plan` / `apply` print the planned/applied changes and any conflicts as tables. Pass `--output json` (or `--output yaml`) to emit the full machine-readable result (`PushResult`) for piping into other tools or CI.
348
+
349
+ ```bash
350
+ # CI gate: fail the build if the branch has drifted from the policy
351
+ neon config plan --project-id polished-snowflake-12345678 --output json
352
+
353
+ # Reconcile a feature branch, overriding any manual tweaks made in the console
354
+ neon deploy --branch my-feature --update-existing
355
+ ```
356
+
357
+ Function deploys declared under `preview.functions` are bundled by neonctl's own esbuild helper and uploaded as part of `apply`, so the policy stays declarative and the packaged CLI never has to embed esbuild's native binary.
358
+
272
359
  ## Commands
273
360
 
274
- | Command | Subcommands | Description |
275
- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------ |
276
- | [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
277
- | [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
278
- | [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
279
- | [me](https://neon.com/docs/reference/cli-me) | | Show current user |
280
- | [branches](https://neon.com/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-default`, `set-expiration`, `delete`, `get` | Manage branches |
281
- | [databases](https://neon.com/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
282
- | functions | `deploy`, `list`, `get`, `delete` | Manage Neon Functions |
283
- | [roles](https://neon.com/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
284
- | [operations](https://neon.com/docs/reference/cli-operations) | `list` | Manage operations |
285
- | [connection-string](https://neon.com/docs/reference/cli-connection-string) | | Get connection string |
286
- | psql | | Connect to a database via psql |
287
- | [set-context](https://neon.com/docs/reference/cli-set-context) | | Set context for session |
288
- | checkout | | Pin a branch in `.neon` |
289
- | [link](https://neon.com/docs/reference/cli-link) | | Link a directory to a project |
290
- | [completion](https://neon.com/docs/reference/cli-completion) | | Generate a completion script |
361
+ | Command | Subcommands | Description |
362
+ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------- |
363
+ | [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
364
+ | [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
365
+ | [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
366
+ | [me](https://neon.com/docs/reference/cli-me) | | Show current user |
367
+ | [branches](https://neon.com/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-default`, `set-expiration`, `delete`, `get` | Manage branches |
368
+ | [databases](https://neon.com/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
369
+ | functions | `deploy`, `list`, `get`, `delete` | Manage Neon Functions |
370
+ | [roles](https://neon.com/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
371
+ | [operations](https://neon.com/docs/reference/cli-operations) | `list` | Manage operations |
372
+ | [connection-string](https://neon.com/docs/reference/cli-connection-string) | | Get connection string |
373
+ | psql | | Connect to a database via psql |
374
+ | [set-context](https://neon.com/docs/reference/cli-set-context) | | Set context for session |
375
+ | env | `pull` | Manage a branch's env vars |
376
+ | checkout | | Pin a branch in `.neon` |
377
+ | [link](https://neon.com/docs/reference/cli-link) | | Link a directory to a project |
378
+ | config | `status`, `plan`, `apply` | Drive a branch from `neon.ts` |
379
+ | deploy | | Alias for `config apply` |
380
+ | bucket | `create`, `list`, `delete`, `object list`, `object get`, `object delete` (incl. `--recursive`) | Manage buckets and their objects |
381
+ | [completion](https://neon.com/docs/reference/cli-completion) | | Generate a completion script |
291
382
 
292
383
  ## Global options
293
384
 
package/commands/auth.js CHANGED
@@ -100,6 +100,11 @@ export const ensureAuth = async (props) => {
100
100
  if (props._.length === 0 || props.help) {
101
101
  return;
102
102
  }
103
+ // `dev` runs a function locally. It injects the selected branch's env vars
104
+ // when credentials happen to be available, but must never trigger an
105
+ // interactive login: use an API key or existing stored credentials if
106
+ // present, otherwise run with no API client (env injection is skipped).
107
+ const isLocalDev = props._[0] === 'dev';
103
108
  // Use existing API key or handle auth command
104
109
  if (props.apiKey || props._[0] === 'auth') {
105
110
  if (props.apiKey) {
@@ -141,6 +146,13 @@ export const ensureAuth = async (props) => {
141
146
  else {
142
147
  log.debug('Credentials file %s does not exist, starting authentication', credentialsPath);
143
148
  }
149
+ // `dev` never launches the interactive browser flow. With no usable
150
+ // credentials it proceeds without an API client; env injection is skipped
151
+ // and the function still runs locally.
152
+ if (isLocalDev) {
153
+ log.debug('dev: no usable credentials; running without env injection');
154
+ return;
155
+ }
144
156
  // Start new auth flow if no valid token exists or refresh failed
145
157
  const apiKey = await authFlow(props);
146
158
  props.apiKey = apiKey;
@@ -1,4 +1,5 @@
1
1
  import { EndpointType } from '@neondatabase/api-client';
2
+ import { readContextFile } from '../context.js';
2
3
  import { writer } from '../writer.js';
3
4
  import { branchCreateRequest } from '../parameters.gen.js';
4
5
  import { retryOnLock } from '../api.js';
@@ -219,25 +220,32 @@ const list = async (props) => {
219
220
  const { data: { branches, annotations }, } = await props.apiClient.listProjectBranches({
220
221
  projectId: props.projectId,
221
222
  });
223
+ // The branch pinned in the local context (.neon), so we can flag it as `[current]` — the
224
+ // one commands target by default and that `neonctl env pull` would read.
225
+ const currentBranchId = readContextFile(props.contextFile).branchId;
222
226
  writer(props).end(branches, {
223
227
  fields: BRANCH_FIELDS,
224
228
  renderColumns: {
225
229
  expires_at: (br) => br.expires_at || 'never',
230
+ // Word labels (not symbols) so they read clearly and match the existing `[anon]`.
226
231
  name: (br) => {
227
232
  const annotation = annotations[br.id];
228
233
  const isAnon = annotation?.value.anonymized;
229
- const result = [];
234
+ const labels = [];
230
235
  if (br.default) {
231
- result.push('');
236
+ labels.push('[default]');
232
237
  }
233
238
  if (br.protected) {
234
- result.push('');
239
+ labels.push('[protected]');
235
240
  }
236
241
  if (isAnon) {
237
- result.push('[anon]');
242
+ labels.push('[anon]');
238
243
  }
239
- result.push(br.name);
240
- return result.join(' ');
244
+ if (currentBranchId !== undefined && br.id === currentBranchId) {
245
+ labels.push('[current]');
246
+ }
247
+ labels.push(br.name);
248
+ return labels.join(' ');
241
249
  },
242
250
  },
243
251
  });
@@ -0,0 +1,368 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { unlink } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { isAxiosError } from 'axios';
6
+ import { retryOnLock } from '../api.js';
7
+ import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
8
+ import { log } from '../log.js';
9
+ import { writer } from '../writer.js';
10
+ import { createProjectBranchBucket, listProjectBranchBuckets, deleteProjectBranchBucket, listProjectBranchBucketObjects, getProjectBranchBucketObject, deleteProjectBranchBucketObject, deleteProjectBranchBucketObjectsByPrefix, } from '../storage_api.js';
11
+ const OBJECT_FIELDS = ['key', 'size', 'last_modified', 'etag'];
12
+ const BUCKET_FIELDS = ['name', 'access_level'];
13
+ const ACCESS_LEVELS = ['private', 'public_read'];
14
+ // Ambient scope shared by every bucket sub-command. The bucket name (and the
15
+ // object key/prefix) is always a positional, never a flag.
16
+ const scopeOptions = {
17
+ 'project-id': {
18
+ describe: 'Project ID',
19
+ type: 'string',
20
+ },
21
+ branch: {
22
+ describe: 'Branch ID or name',
23
+ type: 'string',
24
+ },
25
+ };
26
+ // Split an object target into its bucket and the remainder (key or prefix) on
27
+ // the FIRST `/`. Bucket names are DNS-safe so they never contain a slash; the
28
+ // remainder may contain further slashes and is returned verbatim. When the
29
+ // target has no slash, `rest` is the empty string.
30
+ export const splitBucketTarget = (target) => {
31
+ const slash = target.indexOf('/');
32
+ if (slash === -1) {
33
+ return { bucket: target, rest: '' };
34
+ }
35
+ return {
36
+ bucket: target.slice(0, slash),
37
+ rest: target.slice(slash + 1),
38
+ };
39
+ };
40
+ export const command = 'bucket';
41
+ export const describe = 'Manage branch object-storage buckets and their objects';
42
+ export const builder = (argv) => argv
43
+ .usage('$0 bucket <sub-command> [options]')
44
+ .options({
45
+ 'project-id': {
46
+ describe: 'Project ID',
47
+ type: 'string',
48
+ },
49
+ })
50
+ .middleware(fillSingleProject)
51
+ .command('create <name>', 'Create a bucket on a branch', (yargs) => yargs
52
+ .usage('$0 bucket create <name> [options]')
53
+ .positional('name', {
54
+ describe: 'The bucket name to create',
55
+ type: 'string',
56
+ demandOption: true,
57
+ })
58
+ .options({
59
+ ...scopeOptions,
60
+ 'access-level': {
61
+ describe: 'The visibility of the bucket',
62
+ type: 'string',
63
+ choices: ACCESS_LEVELS,
64
+ default: 'private',
65
+ },
66
+ }), (args) => createBucket(args))
67
+ .command({
68
+ command: 'list',
69
+ aliases: ['ls'],
70
+ describe: 'List the buckets on a branch',
71
+ builder: (yargs) => yargs.usage('$0 bucket list [options]').options(scopeOptions),
72
+ handler: (args) => listBuckets(args),
73
+ })
74
+ .command({
75
+ command: 'delete <name>',
76
+ aliases: ['rm'],
77
+ describe: 'Delete a bucket from a branch',
78
+ builder: (yargs) => yargs
79
+ .usage('$0 bucket delete <name> [options]')
80
+ .positional('name', {
81
+ describe: 'The bucket name to delete',
82
+ type: 'string',
83
+ demandOption: true,
84
+ })
85
+ .options(scopeOptions),
86
+ handler: (args) => deleteBucket(args),
87
+ })
88
+ .command('object <sub-command>', 'List, download or delete objects in a bucket', (yargs) => yargs
89
+ .usage('$0 bucket object <sub-command> [options]')
90
+ .command({
91
+ command: 'list <target>',
92
+ aliases: ['ls'],
93
+ describe: 'List objects in a bucket',
94
+ builder: (yargs) => yargs
95
+ .usage('$0 bucket object list <bucket>[/<prefix>] [options]')
96
+ .positional('target', {
97
+ describe: 'The bucket to list, optionally with a key prefix: <bucket>[/<prefix>]',
98
+ type: 'string',
99
+ demandOption: true,
100
+ })
101
+ .options({
102
+ ...scopeOptions,
103
+ delimiter: {
104
+ describe: 'Collapse keys sharing a common prefix (e.g. "/") into folders',
105
+ type: 'string',
106
+ },
107
+ cursor: {
108
+ describe: 'Pagination cursor returned as next_cursor by a previous call',
109
+ type: 'string',
110
+ },
111
+ limit: {
112
+ describe: 'Maximum number of items (objects + folders) to return',
113
+ type: 'number',
114
+ },
115
+ }),
116
+ handler: (args) => listObjects(args),
117
+ })
118
+ .command('get <target>', 'Download an object from a bucket to a local file', (yargs) => yargs
119
+ .usage('$0 bucket object get <bucket>/<key> [options]')
120
+ .positional('target', {
121
+ describe: 'The object to download: <bucket>/<key>',
122
+ type: 'string',
123
+ demandOption: true,
124
+ })
125
+ .options({
126
+ ...scopeOptions,
127
+ file: {
128
+ describe: 'Path to write the downloaded object to (defaults to the object filename in the current directory)',
129
+ type: 'string',
130
+ },
131
+ }), (args) => getObject(args))
132
+ .command({
133
+ command: 'delete <target>',
134
+ aliases: ['rm'],
135
+ describe: 'Delete an object, or every object under a prefix',
136
+ builder: (yargs) => yargs
137
+ .usage('$0 bucket object delete <bucket>/<key> [options]')
138
+ .positional('target', {
139
+ describe: 'The object to delete: <bucket>/<key>, or <bucket>/<prefix>/ with --recursive',
140
+ type: 'string',
141
+ demandOption: true,
142
+ })
143
+ .options({
144
+ ...scopeOptions,
145
+ recursive: {
146
+ describe: 'Delete every object under the given prefix. The prefix must end with "/"',
147
+ type: 'boolean',
148
+ default: false,
149
+ },
150
+ }),
151
+ handler: (args) => deleteObject(args),
152
+ })
153
+ .demandCommand(1, '')
154
+ .strictCommands())
155
+ .demandCommand(1, '');
156
+ export const handler = (args) => {
157
+ return args;
158
+ };
159
+ const createBucket = async (props) => {
160
+ const branchId = await branchIdFromProps(props);
161
+ const { data } = await retryOnLock(() => createProjectBranchBucket(props.apiClient, {
162
+ projectId: props.projectId,
163
+ branchId,
164
+ name: props.name,
165
+ accessLevel: props.accessLevel,
166
+ }));
167
+ log.info(`Bucket "${data.bucket.name}" (${data.bucket.access_level}) created on branch ${branchId}`);
168
+ };
169
+ const listBuckets = async (props) => {
170
+ const branchId = await branchIdFromProps(props);
171
+ const { data } = await listProjectBranchBuckets(props.apiClient, {
172
+ projectId: props.projectId,
173
+ branchId,
174
+ });
175
+ if (props.output === 'json' || props.output === 'yaml') {
176
+ writer(props).end(data.buckets, { fields: BUCKET_FIELDS });
177
+ return;
178
+ }
179
+ writer(props).end(data.buckets, {
180
+ fields: BUCKET_FIELDS,
181
+ title: 'buckets',
182
+ emptyMessage: 'No buckets found.',
183
+ });
184
+ };
185
+ const deleteBucket = async (props) => {
186
+ const branchId = await branchIdFromProps(props);
187
+ try {
188
+ await retryOnLock(() => deleteProjectBranchBucket(props.apiClient, {
189
+ projectId: props.projectId,
190
+ branchId,
191
+ bucketName: props.name,
192
+ }));
193
+ }
194
+ catch (err) {
195
+ if (isAxiosError(err) && err.response?.status === 404) {
196
+ throw new Error(`Bucket "${props.name}" not found on branch ${branchId}.`);
197
+ }
198
+ throw err;
199
+ }
200
+ log.info(`Bucket "${props.name}" deleted from branch ${branchId}`);
201
+ };
202
+ const listObjects = async (props) => {
203
+ const branchId = await branchIdFromProps(props);
204
+ const { bucket, rest } = splitBucketTarget(props.target);
205
+ const { data } = await listProjectBranchBucketObjects(props.apiClient, {
206
+ projectId: props.projectId,
207
+ branchId,
208
+ bucketName: bucket,
209
+ prefix: rest === '' ? undefined : rest,
210
+ delimiter: props.delimiter,
211
+ cursor: props.cursor,
212
+ limit: props.limit,
213
+ });
214
+ if (props.output === 'json' || props.output === 'yaml') {
215
+ writer(props).end(data, {
216
+ fields: ['folders', 'objects', 'prefix', 'next_cursor', 'is_truncated'],
217
+ });
218
+ return;
219
+ }
220
+ const w = writer(props);
221
+ if (data.folders.length > 0) {
222
+ w.write(data.folders.map((name) => ({ name })), { fields: ['name'], title: 'folders' });
223
+ }
224
+ w.write(data.objects, {
225
+ fields: OBJECT_FIELDS,
226
+ title: 'objects',
227
+ emptyMessage: 'No objects found.',
228
+ });
229
+ w.end();
230
+ if (data.is_truncated && data.next_cursor) {
231
+ log.info(`More results available. Re-run with --cursor ${data.next_cursor} to fetch the next page.`);
232
+ }
233
+ };
234
+ // Pull a filename out of a `Content-Disposition` header, falling back to the
235
+ // last segment of the object key. Handles the plain and RFC 5987 (`filename*=`)
236
+ // forms the download endpoint may emit.
237
+ const filenameFromContentDisposition = (contentDisposition, key) => {
238
+ if (contentDisposition) {
239
+ const extended = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(contentDisposition);
240
+ if (extended?.[1]) {
241
+ try {
242
+ return basename(decodeURIComponent(extended[1].trim()));
243
+ }
244
+ catch {
245
+ // Fall through to the plain form / key on malformed encoding.
246
+ }
247
+ }
248
+ const plain = /filename="?([^";]+)"?/i.exec(contentDisposition);
249
+ if (plain?.[1]) {
250
+ return basename(plain[1].trim());
251
+ }
252
+ }
253
+ return basename(key) || key;
254
+ };
255
+ // Pull the `message` field out of a server error body, returning undefined when
256
+ // the body is absent, not an object, or carries no usable message.
257
+ const serverErrorMessage = (body) => {
258
+ const message = body?.message;
259
+ return typeof message === 'string' && message.trim() !== ''
260
+ ? message
261
+ : undefined;
262
+ };
263
+ // Drain a streamed error body (the form an `octet-stream` download 404 takes)
264
+ // and parse its `message`. Returns undefined on any read/parse failure so the
265
+ // caller falls back to its default message.
266
+ const streamErrorMessage = async (stream) => {
267
+ if (typeof stream?.[Symbol.asyncIterator] !== 'function') {
268
+ return undefined;
269
+ }
270
+ try {
271
+ const chunks = [];
272
+ for await (const chunk of stream) {
273
+ chunks.push(Buffer.from(chunk));
274
+ }
275
+ return serverErrorMessage(JSON.parse(Buffer.concat(chunks).toString()));
276
+ }
277
+ catch {
278
+ return undefined;
279
+ }
280
+ };
281
+ const objectNotFoundFallback = (key, bucket, branchId) => `Object "${key}" not found in bucket "${bucket}" on branch ${branchId}.`;
282
+ // Prefer the server's error message when present so a missing bucket is not
283
+ // misreported as a missing object; otherwise fall back to a clean default. Used
284
+ // for the JSON (non-streamed) endpoints where the body is already parsed.
285
+ const objectNotFoundMessage = (err, key, bucket, branchId) => {
286
+ if (isAxiosError(err)) {
287
+ const serverMessage = serverErrorMessage(err.response?.data);
288
+ if (serverMessage !== undefined) {
289
+ return serverMessage;
290
+ }
291
+ }
292
+ return objectNotFoundFallback(key, bucket, branchId);
293
+ };
294
+ const getObject = async (props) => {
295
+ const branchId = await branchIdFromProps(props);
296
+ const { bucket, rest: key } = splitBucketTarget(props.target);
297
+ if (key === '') {
298
+ throw new Error('Object target must be in the form <bucket>/<key>.');
299
+ }
300
+ let response;
301
+ try {
302
+ response = await getProjectBranchBucketObject(props.apiClient, {
303
+ projectId: props.projectId,
304
+ branchId,
305
+ bucketName: bucket,
306
+ objectKey: key,
307
+ });
308
+ }
309
+ catch (err) {
310
+ if (isAxiosError(err) && err.response?.status === 404) {
311
+ // The download response is a stream, so a 404 body arrives as a stream
312
+ // too; drain and parse it to recover the server's message (which
313
+ // distinguishes a missing bucket from a missing object).
314
+ const serverMessage = await streamErrorMessage(err.response.data);
315
+ throw new Error(serverMessage ?? objectNotFoundFallback(key, bucket, branchId));
316
+ }
317
+ throw err;
318
+ }
319
+ const contentDisposition = response.headers['content-disposition'];
320
+ const destination = props.file ?? filenameFromContentDisposition(contentDisposition, key);
321
+ try {
322
+ await pipeline(response.data, createWriteStream(destination));
323
+ }
324
+ catch (err) {
325
+ // Best-effort cleanup of the partial file before rethrowing.
326
+ await unlink(destination).catch(() => undefined);
327
+ throw err;
328
+ }
329
+ log.info(`Object "${key}" downloaded from bucket "${bucket}" on branch ${branchId} to ${destination}`);
330
+ };
331
+ const deleteObject = async (props) => {
332
+ const branchId = await branchIdFromProps(props);
333
+ const { bucket, rest } = splitBucketTarget(props.target);
334
+ if (props.recursive) {
335
+ if (rest === '') {
336
+ throw new Error('Recursive delete requires a non-empty prefix ending in "/".');
337
+ }
338
+ if (!rest.endsWith('/')) {
339
+ throw new Error(`Recursive delete requires a prefix ending in "/" (got "${rest}").`);
340
+ }
341
+ const { data } = await retryOnLock(() => deleteProjectBranchBucketObjectsByPrefix(props.apiClient, {
342
+ projectId: props.projectId,
343
+ branchId,
344
+ bucketName: bucket,
345
+ prefix: rest,
346
+ }));
347
+ log.info(`Deleted ${data.deleted} object(s) under prefix "${rest}" from bucket "${bucket}" on branch ${branchId}`);
348
+ return;
349
+ }
350
+ if (rest === '') {
351
+ throw new Error('Object target must be in the form <bucket>/<key>.');
352
+ }
353
+ try {
354
+ await retryOnLock(() => deleteProjectBranchBucketObject(props.apiClient, {
355
+ projectId: props.projectId,
356
+ branchId,
357
+ bucketName: bucket,
358
+ objectKey: rest,
359
+ }));
360
+ }
361
+ catch (err) {
362
+ if (isAxiosError(err) && err.response?.status === 404) {
363
+ throw new Error(objectNotFoundMessage(err, rest, bucket, branchId));
364
+ }
365
+ throw err;
366
+ }
367
+ log.info(`Object "${rest}" deleted from bucket "${bucket}" on branch ${branchId}`);
368
+ };