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 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:
@@ -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? › [default] 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,6 +277,24 @@ $ 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.
@@ -328,25 +358,27 @@ Function deploys declared under `preview.functions` are bundled by neonctl's own
328
358
 
329
359
  ## Commands
330
360
 
331
- | Command | Subcommands | Description |
332
- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------ |
333
- | [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
334
- | [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
335
- | [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
336
- | [me](https://neon.com/docs/reference/cli-me) | | Show current user |
337
- | [branches](https://neon.com/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-default`, `set-expiration`, `delete`, `get` | Manage branches |
338
- | [databases](https://neon.com/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
339
- | functions | `deploy`, `list`, `get`, `delete` | Manage Neon Functions |
340
- | [roles](https://neon.com/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
341
- | [operations](https://neon.com/docs/reference/cli-operations) | `list` | Manage operations |
342
- | [connection-string](https://neon.com/docs/reference/cli-connection-string) | | Get connection string |
343
- | psql | | Connect to a database via psql |
344
- | [set-context](https://neon.com/docs/reference/cli-set-context) | | Set context for session |
345
- | checkout | | Pin a branch in `.neon` |
346
- | [link](https://neon.com/docs/reference/cli-link) | | Link a directory to a project |
347
- | config | `status`, `plan`, `apply` | Drive a branch from `neon.ts` |
348
- | deploy | | Alias for `config apply` |
349
- | [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 |
350
382
 
351
383
  ## Global options
352
384
 
@@ -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
+ };