neonctl 2.37.0 → 2.38.0

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.
Files changed (160) hide show
  1. package/{LICENSE.md → LICENSE} +27 -3
  2. package/README.md +15 -700
  3. package/bin/cli.js +3 -0
  4. package/package.json +8 -75
  5. package/dist/analytics.js +0 -168
  6. package/dist/api.js +0 -720
  7. package/dist/auth.js +0 -125
  8. package/dist/callback.html +0 -51
  9. package/dist/cli.js +0 -9
  10. package/dist/commands/api.js +0 -278
  11. package/dist/commands/auth.js +0 -214
  12. package/dist/commands/bootstrap.js +0 -481
  13. package/dist/commands/branches.js +0 -488
  14. package/dist/commands/bucket.js +0 -549
  15. package/dist/commands/checkout.js +0 -321
  16. package/dist/commands/config.js +0 -626
  17. package/dist/commands/connection_string.js +0 -172
  18. package/dist/commands/data_api.js +0 -284
  19. package/dist/commands/databases.js +0 -82
  20. package/dist/commands/deploy.js +0 -26
  21. package/dist/commands/dev.js +0 -698
  22. package/dist/commands/diff.js +0 -222
  23. package/dist/commands/env.js +0 -189
  24. package/dist/commands/functions.js +0 -373
  25. package/dist/commands/index.js +0 -62
  26. package/dist/commands/init.js +0 -73
  27. package/dist/commands/inspect.js +0 -65
  28. package/dist/commands/ip_allow.js +0 -137
  29. package/dist/commands/link.js +0 -1121
  30. package/dist/commands/neon_auth.js +0 -1028
  31. package/dist/commands/operations.js +0 -28
  32. package/dist/commands/orgs.js +0 -24
  33. package/dist/commands/projects.js +0 -413
  34. package/dist/commands/psql.js +0 -62
  35. package/dist/commands/roles.js +0 -65
  36. package/dist/commands/schema_diff.js +0 -151
  37. package/dist/commands/set_context.js +0 -29
  38. package/dist/commands/snapshots.js +0 -455
  39. package/dist/commands/status.js +0 -40
  40. package/dist/commands/user.js +0 -15
  41. package/dist/commands/vpc_endpoints.js +0 -134
  42. package/dist/config.js +0 -11
  43. package/dist/config_format.js +0 -72
  44. package/dist/context.js +0 -231
  45. package/dist/current_branch_fast_path.js +0 -55
  46. package/dist/dev/env.js +0 -244
  47. package/dist/dev/functions.js +0 -70
  48. package/dist/dev/inputs.js +0 -63
  49. package/dist/dev/runtime.js +0 -146
  50. package/dist/env.js +0 -36
  51. package/dist/env_file.js +0 -159
  52. package/dist/errors.js +0 -80
  53. package/dist/functions_api.js +0 -48
  54. package/dist/help.js +0 -146
  55. package/dist/index.js +0 -240
  56. package/dist/log.js +0 -18
  57. package/dist/parameters.gen.js +0 -480
  58. package/dist/pkg.js +0 -25
  59. package/dist/psql/cli.js +0 -53
  60. package/dist/psql/command/cmd_cond.js +0 -437
  61. package/dist/psql/command/cmd_connect.js +0 -820
  62. package/dist/psql/command/cmd_copy.js +0 -1035
  63. package/dist/psql/command/cmd_describe.js +0 -1815
  64. package/dist/psql/command/cmd_format.js +0 -948
  65. package/dist/psql/command/cmd_io.js +0 -2193
  66. package/dist/psql/command/cmd_lo.js +0 -393
  67. package/dist/psql/command/cmd_meta.js +0 -969
  68. package/dist/psql/command/cmd_misc.js +0 -187
  69. package/dist/psql/command/cmd_pipeline.js +0 -1148
  70. package/dist/psql/command/cmd_restrict.js +0 -171
  71. package/dist/psql/command/cmd_show.js +0 -766
  72. package/dist/psql/command/dispatch.js +0 -343
  73. package/dist/psql/command/inputQueue.js +0 -42
  74. package/dist/psql/command/shared.js +0 -71
  75. package/dist/psql/complete/filenames.js +0 -139
  76. package/dist/psql/complete/index.js +0 -104
  77. package/dist/psql/complete/matcher.js +0 -315
  78. package/dist/psql/complete/psqlVars.js +0 -249
  79. package/dist/psql/complete/queries.js +0 -493
  80. package/dist/psql/complete/rules.js +0 -2424
  81. package/dist/psql/core/common.js +0 -1253
  82. package/dist/psql/core/help.js +0 -576
  83. package/dist/psql/core/mainloop.js +0 -1360
  84. package/dist/psql/core/prompt.js +0 -439
  85. package/dist/psql/core/settings.js +0 -686
  86. package/dist/psql/core/sqlHelp.js +0 -1066
  87. package/dist/psql/core/startup.js +0 -846
  88. package/dist/psql/core/syncVars.js +0 -116
  89. package/dist/psql/core/variables.js +0 -287
  90. package/dist/psql/describe/formatters.js +0 -1330
  91. package/dist/psql/describe/processNamePattern.js +0 -270
  92. package/dist/psql/describe/queries.js +0 -2452
  93. package/dist/psql/describe/versionGate.js +0 -44
  94. package/dist/psql/index.js +0 -2030
  95. package/dist/psql/io/history.js +0 -299
  96. package/dist/psql/io/input.js +0 -120
  97. package/dist/psql/io/lineEditor/buffer.js +0 -325
  98. package/dist/psql/io/lineEditor/complete.js +0 -227
  99. package/dist/psql/io/lineEditor/filename.js +0 -159
  100. package/dist/psql/io/lineEditor/index.js +0 -893
  101. package/dist/psql/io/lineEditor/keymap.js +0 -745
  102. package/dist/psql/io/lineEditor/vt100.js +0 -363
  103. package/dist/psql/io/pgpass.js +0 -202
  104. package/dist/psql/io/pgservice.js +0 -194
  105. package/dist/psql/io/psqlrc.js +0 -422
  106. package/dist/psql/print/aligned.js +0 -1748
  107. package/dist/psql/print/asciidoc.js +0 -230
  108. package/dist/psql/print/crosstab.js +0 -463
  109. package/dist/psql/print/csv.js +0 -76
  110. package/dist/psql/print/html.js +0 -240
  111. package/dist/psql/print/json.js +0 -96
  112. package/dist/psql/print/latex.js +0 -379
  113. package/dist/psql/print/pager.js +0 -267
  114. package/dist/psql/print/troff.js +0 -240
  115. package/dist/psql/print/unaligned.js +0 -99
  116. package/dist/psql/print/units.js +0 -188
  117. package/dist/psql/scanner/slash.js +0 -515
  118. package/dist/psql/scanner/sql.js +0 -914
  119. package/dist/psql/scanner/stringutils.js +0 -394
  120. package/dist/psql/types/backslash.js +0 -1
  121. package/dist/psql/types/connection.js +0 -1
  122. package/dist/psql/types/index.js +0 -7
  123. package/dist/psql/types/printer.js +0 -1
  124. package/dist/psql/types/repl.js +0 -1
  125. package/dist/psql/types/scanner.js +0 -24
  126. package/dist/psql/types/settings.js +0 -1
  127. package/dist/psql/types/variables.js +0 -1
  128. package/dist/psql/wire/connection.js +0 -2857
  129. package/dist/psql/wire/copy.js +0 -108
  130. package/dist/psql/wire/notify.js +0 -59
  131. package/dist/psql/wire/pipeline.js +0 -521
  132. package/dist/psql/wire/protocol.js +0 -466
  133. package/dist/psql/wire/sasl.js +0 -294
  134. package/dist/psql/wire/tls.js +0 -602
  135. package/dist/storage_api.js +0 -147
  136. package/dist/test_utils/fixtures.js +0 -121
  137. package/dist/test_utils/oauth_server.js +0 -9
  138. package/dist/types.js +0 -1
  139. package/dist/utils/ai_gateway_notice.js +0 -180
  140. package/dist/utils/api_enums.js +0 -33
  141. package/dist/utils/auth.js +0 -5
  142. package/dist/utils/branch_notice.js +0 -22
  143. package/dist/utils/branch_picker.js +0 -103
  144. package/dist/utils/compute_units.js +0 -28
  145. package/dist/utils/config_diff.js +0 -185
  146. package/dist/utils/enrichers.js +0 -161
  147. package/dist/utils/esbuild.js +0 -158
  148. package/dist/utils/formats.js +0 -18
  149. package/dist/utils/git_diff.js +0 -90
  150. package/dist/utils/inspect_db.js +0 -153
  151. package/dist/utils/inspect_queries.js +0 -372
  152. package/dist/utils/middlewares.js +0 -20
  153. package/dist/utils/openapi.js +0 -114
  154. package/dist/utils/package_manager.js +0 -68
  155. package/dist/utils/point_in_time.js +0 -56
  156. package/dist/utils/psql.js +0 -120
  157. package/dist/utils/string.js +0 -5
  158. package/dist/utils/ui.js +0 -59
  159. package/dist/utils/zip.js +0 -4
  160. package/dist/writer.js +0 -97
@@ -1,549 +0,0 @@
1
- import { createReadStream, createWriteStream } from "node:fs";
2
- import { stat, unlink } from "node:fs/promises";
3
- import { basename } from "node:path";
4
- import { pipeline } from "node:stream/promises";
5
- import { isNeonApiError, retryOnLock } from "../api.js";
6
- import { log } from "../log.js";
7
- import { createProjectBranchBucket, deleteProjectBranchBucket, deleteProjectBranchBucketObject, deleteProjectBranchBucketObjectsByPrefix, getProjectBranchBucketObject, listProjectBranchBucketObjects, listProjectBranchBuckets, presignUpload, } from "../storage_api.js";
8
- import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
9
- import { writer } from "../writer.js";
10
- const OBJECT_FIELDS = ["key", "size", "last_modified", "etag"];
11
- const BUCKET_FIELDS = ["name", "access_level"];
12
- const ACCESS_LEVELS = ["private", "public_read"];
13
- // Single-PUT upload cap. Objects larger than this must use multipart upload,
14
- // which is out of scope for v1; we reject them client-side before any HTTP so
15
- // the user gets an immediate, clear error rather than a server-side rejection
16
- // part-way through a large transfer.
17
- const MAX_OBJECT_BYTES = 100 * 1024 * 1024; // 100 MB
18
- // Ambient scope shared by every bucket sub-command. The bucket name (and the
19
- // object key/prefix) is always a positional, never a flag.
20
- const scopeOptions = {
21
- "project-id": {
22
- describe: "Project ID",
23
- type: "string",
24
- },
25
- branch: {
26
- describe: "Branch ID or name",
27
- type: "string",
28
- },
29
- };
30
- // Split an object target into its bucket and the remainder (key or prefix) on
31
- // the FIRST `/`. Bucket names are DNS-safe so they never contain a slash; the
32
- // remainder may contain further slashes and is returned verbatim. When the
33
- // target has no slash, `rest` is the empty string.
34
- export const splitBucketTarget = (target) => {
35
- const slash = target.indexOf("/");
36
- if (slash === -1) {
37
- return { bucket: target, rest: "" };
38
- }
39
- return {
40
- bucket: target.slice(0, slash),
41
- rest: target.slice(slash + 1),
42
- };
43
- };
44
- export const command = "buckets";
45
- export const describe = "Manage branch object-storage buckets and their objects";
46
- export const aliases = ["bucket"];
47
- export const builder = (argv) => argv
48
- .usage("$0 bucket <sub-command> [options]")
49
- .options({
50
- "project-id": {
51
- describe: "Project ID",
52
- type: "string",
53
- },
54
- })
55
- .middleware(fillSingleProject)
56
- .command("create <name>", "Create a bucket on a branch", (yargs) => yargs
57
- .usage("$0 bucket create <name> [options]")
58
- .positional("name", {
59
- describe: "The bucket name to create",
60
- type: "string",
61
- demandOption: true,
62
- })
63
- .options({
64
- ...scopeOptions,
65
- "access-level": {
66
- describe: "The visibility of the bucket",
67
- type: "string",
68
- choices: ACCESS_LEVELS,
69
- default: "private",
70
- },
71
- }), (args) => createBucket(args))
72
- .command({
73
- command: "list",
74
- aliases: ["ls"],
75
- describe: "List the buckets on a branch",
76
- builder: (yargs) => yargs.usage("$0 bucket list [options]").options(scopeOptions),
77
- handler: (args) => listBuckets(args),
78
- })
79
- .command({
80
- command: "delete <name>",
81
- aliases: ["rm"],
82
- describe: "Delete a bucket from a branch",
83
- builder: (yargs) => yargs
84
- .usage("$0 bucket delete <name> [options]")
85
- .positional("name", {
86
- describe: "The bucket name to delete",
87
- type: "string",
88
- demandOption: true,
89
- })
90
- .options(scopeOptions),
91
- handler: (args) => deleteBucket(args),
92
- })
93
- .command("object <sub-command>", "List, download, upload or delete objects in a bucket", (yargs) => yargs
94
- .usage("$0 bucket object <sub-command> [options]")
95
- .command({
96
- command: "list <target>",
97
- aliases: ["ls"],
98
- describe: 'List objects in a bucket. By default folders are collapsed (like "aws s3 ls"); pass --recursive for a flat listing of every key',
99
- builder: (yargs) => yargs
100
- .usage("$0 bucket object list <bucket>[/<prefix>] [options]")
101
- .positional("target", {
102
- describe: "The bucket to list, optionally with a key prefix: <bucket>[/<prefix>]",
103
- type: "string",
104
- demandOption: true,
105
- })
106
- .options({
107
- ...scopeOptions,
108
- recursive: {
109
- describe: 'List every key flat, descending into nested folders (no delimiter). Mutually exclusive with --delimiter. Mirrors "aws s3 ls --recursive"',
110
- type: "boolean",
111
- default: false,
112
- },
113
- delimiter: {
114
- describe: 'Collapse keys sharing this prefix separator into folders. Defaults to "/" (folder view); ignored when --recursive is set',
115
- type: "string",
116
- },
117
- cursor: {
118
- describe: "Pagination cursor returned as next_cursor by a previous call",
119
- type: "string",
120
- },
121
- limit: {
122
- describe: "Maximum number of items (objects + folders) to return",
123
- type: "number",
124
- },
125
- }),
126
- handler: (args) => listObjects(args),
127
- })
128
- .command("get <target>", "Download an object from a bucket to a local file", (yargs) => yargs
129
- .usage("$0 bucket object get <bucket>/<key> [options]")
130
- .positional("target", {
131
- describe: "The object to download: <bucket>/<key>",
132
- type: "string",
133
- demandOption: true,
134
- })
135
- .options({
136
- ...scopeOptions,
137
- file: {
138
- describe: "Path to write the downloaded object to (defaults to the object filename in the current directory)",
139
- type: "string",
140
- },
141
- }), (args) => getObject(args))
142
- .command("put <target>", "Upload a local file to a bucket as an object", (yargs) => yargs
143
- .usage("$0 bucket object put <bucket>/<key> [options]")
144
- .positional("target", {
145
- describe: "The object to upload to: <bucket>/<key>",
146
- type: "string",
147
- demandOption: true,
148
- })
149
- .options({
150
- ...scopeOptions,
151
- file: {
152
- describe: "Path to the local file to upload",
153
- type: "string",
154
- demandOption: true,
155
- },
156
- "content-type": {
157
- describe: "Content-Type to store the object with (e.g. text/plain)",
158
- type: "string",
159
- },
160
- }), (args) => putObject(args))
161
- .command({
162
- command: "delete <target>",
163
- aliases: ["rm"],
164
- describe: "Delete an object, or every object under a prefix",
165
- builder: (yargs) => yargs
166
- .usage("$0 bucket object delete <bucket>/<key> [options]")
167
- .positional("target", {
168
- describe: "The object to delete: <bucket>/<key>, or <bucket>/<prefix>/ with --recursive",
169
- type: "string",
170
- demandOption: true,
171
- })
172
- .options({
173
- ...scopeOptions,
174
- recursive: {
175
- describe: 'Delete every object under the given prefix. The prefix must end with "/"',
176
- type: "boolean",
177
- default: false,
178
- },
179
- }),
180
- handler: (args) => deleteObject(args),
181
- })
182
- .demandCommand(1, "")
183
- .strictCommands())
184
- .demandCommand(1, "");
185
- export const handler = (args) => {
186
- return args;
187
- };
188
- const createBucket = async (props) => {
189
- const branchId = await branchIdFromProps(props);
190
- const { data } = await retryOnLock(() => createProjectBranchBucket(props.apiClient, {
191
- projectId: props.projectId,
192
- branchId,
193
- name: props.name,
194
- accessLevel: props.accessLevel,
195
- }));
196
- log.info(`Bucket "${data.bucket.name}" (${data.bucket.access_level}) created on branch ${branchId}`);
197
- };
198
- const listBuckets = async (props) => {
199
- const branchId = await branchIdFromProps(props);
200
- const { data } = await listProjectBranchBuckets(props.apiClient, {
201
- projectId: props.projectId,
202
- branchId,
203
- });
204
- if (props.output === "json" || props.output === "yaml") {
205
- writer(props).end(data.buckets, { fields: BUCKET_FIELDS });
206
- return;
207
- }
208
- writer(props).end(data.buckets, {
209
- fields: BUCKET_FIELDS,
210
- title: "buckets",
211
- emptyMessage: "No buckets found.",
212
- });
213
- };
214
- const deleteBucket = async (props) => {
215
- const branchId = await branchIdFromProps(props);
216
- try {
217
- await retryOnLock(() => deleteProjectBranchBucket(props.apiClient, {
218
- projectId: props.projectId,
219
- branchId,
220
- bucketName: props.name,
221
- }));
222
- }
223
- catch (err) {
224
- if (isNeonApiError(err) && err.status === 404) {
225
- throw new Error(`Bucket "${props.name}" not found on branch ${branchId}.`);
226
- }
227
- throw err;
228
- }
229
- log.info(`Bucket "${props.name}" deleted from branch ${branchId}`);
230
- };
231
- // Resolve the delimiter to send to the backend, mirroring `aws s3 ls`:
232
- // - default (neither flag): "/" so the listing is folder-collapsed;
233
- // - --recursive: no delimiter, so every nested key is returned flat;
234
- // - explicit --delimiter <x>: that value (an empty string lists flat too).
235
- // `--recursive` together with an explicit `--delimiter` is nonsensical and is
236
- // rejected client-side before any HTTP request is made.
237
- export const resolveListDelimiter = (props) => {
238
- if (props.recursive && props.delimiter !== undefined) {
239
- throw new Error("--recursive and --delimiter cannot be used together. Use --recursive for a flat listing, or --delimiter to collapse on a separator.");
240
- }
241
- if (props.recursive) {
242
- return undefined;
243
- }
244
- if (props.delimiter !== undefined) {
245
- return props.delimiter;
246
- }
247
- return "/";
248
- };
249
- const listObjects = async (props) => {
250
- const delimiter = resolveListDelimiter(props);
251
- const branchId = await branchIdFromProps(props);
252
- const { bucket, rest } = splitBucketTarget(props.target);
253
- const { data } = await listProjectBranchBucketObjects(props.apiClient, {
254
- projectId: props.projectId,
255
- branchId,
256
- bucketName: bucket,
257
- prefix: rest === "" ? undefined : rest,
258
- delimiter,
259
- cursor: props.cursor,
260
- limit: props.limit,
261
- });
262
- if (props.output === "json" || props.output === "yaml") {
263
- writer(props).end(data, {
264
- fields: [
265
- "folders",
266
- "objects",
267
- "prefix",
268
- "next_cursor",
269
- "is_truncated",
270
- ],
271
- });
272
- return;
273
- }
274
- const w = writer(props);
275
- if (data.folders.length > 0) {
276
- w.write(data.folders.map((name) => ({ name })), { fields: ["name"], title: "folders" });
277
- }
278
- w.write(data.objects, {
279
- fields: OBJECT_FIELDS,
280
- title: "objects",
281
- emptyMessage: "No objects found.",
282
- });
283
- w.end();
284
- if (data.is_truncated && data.next_cursor) {
285
- log.info(`More results available. Re-run with --cursor ${data.next_cursor} to fetch the next page.`);
286
- }
287
- };
288
- // Pull a filename out of a `Content-Disposition` header, falling back to the
289
- // last segment of the object key. Handles the plain and RFC 5987 (`filename*=`)
290
- // forms the download endpoint may emit.
291
- const filenameFromContentDisposition = (contentDisposition, key) => {
292
- if (contentDisposition) {
293
- const extended = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(contentDisposition);
294
- if (extended?.[1]) {
295
- try {
296
- return basename(decodeURIComponent(extended[1].trim()));
297
- }
298
- catch {
299
- // Fall through to the plain form / key on malformed encoding.
300
- }
301
- }
302
- const plain = /filename="?([^";]+)"?/i.exec(contentDisposition);
303
- if (plain?.[1]) {
304
- return basename(plain[1].trim());
305
- }
306
- }
307
- return basename(key) || key;
308
- };
309
- // Pull the `message` field out of a server error body, returning undefined when
310
- // the body is absent, not an object, or carries no usable message.
311
- const serverErrorMessage = (body) => {
312
- const message = body?.message;
313
- return typeof message === "string" && message.trim() !== ""
314
- ? message
315
- : undefined;
316
- };
317
- // Drain a streamed error body (the form an `octet-stream` download 404 takes)
318
- // and parse its `message`. Returns undefined on any read/parse failure so the
319
- // caller falls back to its default message.
320
- const streamErrorMessage = async (stream) => {
321
- if (typeof stream?.[Symbol.asyncIterator] !== "function") {
322
- return undefined;
323
- }
324
- try {
325
- const chunks = [];
326
- for await (const chunk of stream) {
327
- chunks.push(Buffer.from(chunk));
328
- }
329
- return serverErrorMessage(JSON.parse(Buffer.concat(chunks).toString()));
330
- }
331
- catch {
332
- return undefined;
333
- }
334
- };
335
- const objectNotFoundFallback = (key, bucket, branchId) => `Object "${key}" not found in bucket "${bucket}" on branch ${branchId}.`;
336
- // Prefer the server's error message when present so a missing bucket is not
337
- // misreported as a missing object; otherwise fall back to a clean default. Used
338
- // for the JSON (non-streamed) endpoints where the body is already parsed.
339
- const objectNotFoundMessage = (err, key, bucket, branchId) => {
340
- if (isNeonApiError(err)) {
341
- const serverMessage = serverErrorMessage(err.data);
342
- if (serverMessage !== undefined) {
343
- return serverMessage;
344
- }
345
- }
346
- return objectNotFoundFallback(key, bucket, branchId);
347
- };
348
- // Stream a file from disk as a WHATWG `ReadableStream` suitable for a `fetch`
349
- // request body, applying backpressure so we never read faster than the upload
350
- // drains. Uses the global `ReadableStream` (what `fetch` expects) directly, so
351
- // there's no Node-vs-DOM stream type bridging.
352
- const fileToWebStream = (path) => {
353
- const source = createReadStream(path);
354
- return new ReadableStream({
355
- start(controller) {
356
- source.on("data", (chunk) => {
357
- // `createReadStream` with no encoding always emits Buffers at
358
- // runtime; @types/node types the `data` chunk as `string | Buffer`,
359
- // so normalize (a string would only appear if an encoding were set).
360
- const bytes = typeof chunk === "string"
361
- ? new TextEncoder().encode(chunk)
362
- : new Uint8Array(chunk);
363
- controller.enqueue(bytes);
364
- if ((controller.desiredSize ?? 0) <= 0)
365
- source.pause();
366
- });
367
- source.on("end", () => {
368
- controller.close();
369
- });
370
- source.on("error", (err) => {
371
- controller.error(err instanceof Error ? err : new Error(String(err)));
372
- });
373
- },
374
- pull() {
375
- source.resume();
376
- },
377
- cancel() {
378
- source.destroy();
379
- },
380
- });
381
- };
382
- const getObject = async (props) => {
383
- const branchId = await branchIdFromProps(props);
384
- const { bucket, rest: key } = splitBucketTarget(props.target);
385
- if (key === "") {
386
- throw new Error("Object target must be in the form <bucket>/<key>.");
387
- }
388
- let response;
389
- try {
390
- response = await getProjectBranchBucketObject(props.apiClient, {
391
- projectId: props.projectId,
392
- branchId,
393
- bucketName: bucket,
394
- objectKey: key,
395
- });
396
- }
397
- catch (err) {
398
- if (isNeonApiError(err) && err.status === 404) {
399
- // The download response is a stream, so a 404 body arrives as a stream
400
- // too; drain and parse it to recover the server's message (which
401
- // distinguishes a missing bucket from a missing object).
402
- const serverMessage = await streamErrorMessage(err.data);
403
- throw new Error(serverMessage ?? objectNotFoundFallback(key, bucket, branchId));
404
- }
405
- throw err;
406
- }
407
- const contentDisposition = response.headers["content-disposition"];
408
- const destination = props.file ?? filenameFromContentDisposition(contentDisposition, key);
409
- try {
410
- await pipeline(response.data, createWriteStream(destination));
411
- }
412
- catch (err) {
413
- // Best-effort cleanup of the partial file before rethrowing.
414
- await unlink(destination).catch(() => undefined);
415
- throw err;
416
- }
417
- log.info(`Object "${key}" downloaded from bucket "${bucket}" on branch ${branchId} to ${destination}`);
418
- };
419
- const putObject = async (props) => {
420
- const branchId = await branchIdFromProps(props);
421
- const { bucket, rest: key } = splitBucketTarget(props.target);
422
- if (bucket === "" || key === "") {
423
- throw new Error("Object target must be in the form <bucket>/<key>.");
424
- }
425
- // Stat the file first so we fail fast on a missing/unreadable file and can
426
- // enforce the single-PUT size cap BEFORE any network round-trip. We also
427
- // reuse the byte count as the PUT Content-Length so the stream is uploaded
428
- // without buffering the whole file in memory.
429
- let fileSize;
430
- try {
431
- const fileStat = await stat(props.file);
432
- if (!fileStat.isFile()) {
433
- throw new Error(`"${props.file}" is not a regular file.`);
434
- }
435
- fileSize = fileStat.size;
436
- }
437
- catch (err) {
438
- if (err?.code === "ENOENT") {
439
- throw new Error(`File "${props.file}" does not exist.`);
440
- }
441
- throw err;
442
- }
443
- if (fileSize > MAX_OBJECT_BYTES) {
444
- throw new Error(`File "${props.file}" is ${fileSize} bytes, which exceeds the ${MAX_OBJECT_BYTES}-byte (100 MB) single-upload limit. Larger objects are not supported yet.`);
445
- }
446
- // Ask the console for a presigned PUT URL plus the headers that must travel
447
- // with the upload for the signature to verify. No SigV4 happens in neonctl.
448
- let presign;
449
- try {
450
- ({ data: presign } = await presignUpload(props.apiClient, {
451
- projectId: props.projectId,
452
- branchId,
453
- bucketName: bucket,
454
- objectKey: key,
455
- contentType: props.contentType,
456
- }));
457
- }
458
- catch (err) {
459
- if (isNeonApiError(err)) {
460
- const status = err.status;
461
- if (status === 404) {
462
- throw new Error(objectNotFoundMessage(err, key, bucket, branchId));
463
- }
464
- // Any other HTTP error from the console (e.g. 403 when the caller lacks
465
- // write permission on the bucket) carries the same JSON `{ message }`
466
- // body, so surface that rather than a bare error. When the body has no
467
- // usable message, fall back to a clean status-bearing error.
468
- const serverMessage = serverErrorMessage(err.data);
469
- throw new Error(serverMessage ??
470
- `Failed to presign upload for "${key}" in bucket "${bucket}" on branch ${branchId}${status !== undefined ? ` (HTTP ${status})` : ""}: ${err.message}`);
471
- }
472
- throw err;
473
- }
474
- // Stream the file straight into the PUT body via `fetch`; never buffer the
475
- // whole file. The presigned URL targets the branch S3 data-plane endpoint
476
- // directly, so this PUT bypasses the console API entirely.
477
- //
478
- // `presign.headers` carries the signature-relevant headers (e.g. host,
479
- // content-type); the server does not sign Content-Length, so we set it
480
- // ourselves from the stat'd size. `redirect: 'error'` ensures we never resend
481
- // the file bytes and signed headers to a different host if the data-plane
482
- // endpoint were to answer with a redirect. `duplex: 'half'` is required by
483
- // fetch when streaming a request body.
484
- const upload = {
485
- method: "PUT",
486
- headers: {
487
- ...presign.headers,
488
- "Content-Length": String(fileSize),
489
- },
490
- body: fileToWebStream(props.file),
491
- redirect: "error",
492
- duplex: "half",
493
- };
494
- let uploadResponse;
495
- try {
496
- uploadResponse = await fetch(presign.url, upload);
497
- }
498
- catch (err) {
499
- // A transport-level failure (DNS, connection reset, redirect when none is
500
- // allowed). Surface a clean message without leaking the signed URL.
501
- throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId}: ${err instanceof Error ? err.message : String(err)}`);
502
- }
503
- if (!uploadResponse.ok) {
504
- // The upload targets the S3 data plane, whose error bodies are XML rather
505
- // than the JSON `{ message }` the console returns, so surface the status
506
- // rather than the body. Never include the presigned URL, which carries the
507
- // signature.
508
- throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId} (HTTP ${uploadResponse.status}): Request failed with status code ${uploadResponse.status}`);
509
- }
510
- log.info(`File "${props.file}" uploaded to "${key}" in bucket "${bucket}" on branch ${branchId}`);
511
- };
512
- const deleteObject = async (props) => {
513
- const branchId = await branchIdFromProps(props);
514
- const { bucket, rest } = splitBucketTarget(props.target);
515
- if (props.recursive) {
516
- if (rest === "") {
517
- throw new Error('Recursive delete requires a non-empty prefix ending in "/".');
518
- }
519
- if (!rest.endsWith("/")) {
520
- throw new Error(`Recursive delete requires a prefix ending in "/" (got "${rest}").`);
521
- }
522
- const { data } = await retryOnLock(() => deleteProjectBranchBucketObjectsByPrefix(props.apiClient, {
523
- projectId: props.projectId,
524
- branchId,
525
- bucketName: bucket,
526
- prefix: rest,
527
- }));
528
- log.info(`Deleted ${data.deleted} object(s) under prefix "${rest}" from bucket "${bucket}" on branch ${branchId}`);
529
- return;
530
- }
531
- if (rest === "") {
532
- throw new Error("Object target must be in the form <bucket>/<key>.");
533
- }
534
- try {
535
- await retryOnLock(() => deleteProjectBranchBucketObject(props.apiClient, {
536
- projectId: props.projectId,
537
- branchId,
538
- bucketName: bucket,
539
- objectKey: rest,
540
- }));
541
- }
542
- catch (err) {
543
- if (isNeonApiError(err) && err.status === 404) {
544
- throw new Error(objectNotFoundMessage(err, rest, bucket, branchId));
545
- }
546
- throw err;
547
- }
548
- log.info(`Object "${rest}" deleted from bucket "${bucket}" on branch ${branchId}`);
549
- };