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/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
|
|
332
|
-
| -------------------------------------------------------------------------- |
|
|
333
|
-
| [auth](https://neon.com/docs/reference/cli-auth) |
|
|
334
|
-
| [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get`
|
|
335
|
-
| [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset`
|
|
336
|
-
| [me](https://neon.com/docs/reference/cli-me) |
|
|
337
|
-
| [branches](https://neon.com/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-default`, `set-expiration`, `delete`, `get`
|
|
338
|
-
| [databases](https://neon.com/docs/reference/cli-databases) | `list`, `create`, `delete`
|
|
339
|
-
| functions | `deploy`, `list`, `get`, `delete`
|
|
340
|
-
| [roles](https://neon.com/docs/reference/cli-roles) | `list`, `create`, `delete`
|
|
341
|
-
| [operations](https://neon.com/docs/reference/cli-operations) | `list`
|
|
342
|
-
| [connection-string](https://neon.com/docs/reference/cli-connection-string) |
|
|
343
|
-
| psql |
|
|
344
|
-
| [set-context](https://neon.com/docs/reference/cli-set-context) |
|
|
345
|
-
|
|
|
346
|
-
|
|
|
347
|
-
|
|
|
348
|
-
|
|
|
349
|
-
|
|
|
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
|
|
package/commands/branches.js
CHANGED
|
@@ -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
|
|
234
|
+
const labels = [];
|
|
230
235
|
if (br.default) {
|
|
231
|
-
|
|
236
|
+
labels.push('[default]');
|
|
232
237
|
}
|
|
233
238
|
if (br.protected) {
|
|
234
|
-
|
|
239
|
+
labels.push('[protected]');
|
|
235
240
|
}
|
|
236
241
|
if (isAnon) {
|
|
237
|
-
|
|
242
|
+
labels.push('[anon]');
|
|
238
243
|
}
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
};
|