naro-cli 0.15.0 → 0.16.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.
package/README.md CHANGED
@@ -180,7 +180,7 @@ naro storage bucket update avatars --private | --file-size-limit none | --allowe
180
180
  naro storage bucket update docs --public (asks first: anyone with an object's URL can download it)
181
181
  naro storage bucket list | empty <name> | remove <name> [--empty] (empty and remove ask first)
182
182
  naro storage ls naro://avatars/u1 [--recursive]
183
- naro storage cp ./me.png naro://avatars/u1/me.png [--upsert] Up to 50 MiB, with the service key
183
+ naro storage cp ./me.png naro://avatars/u1/me.png [--upsert] Up to the object limit, under 4 GiB (over 50 MiB in parts), with the service key
184
184
  naro storage cp naro://docs/team-1/plan.pdf ./ Replaces a local file of the same name
185
185
  naro storage rm naro://avatars/u1/old.png [--recursive] (asks first)
186
186
  naro storage rm naro://avatars/u1/ --recursive Everything under u1/; written u1, an object u1 too
@@ -793,13 +793,43 @@ An `--update-check` you give replaces the default: without
793
793
 
794
794
  ## Release notes
795
795
 
796
+ ### 0.16.0
797
+
798
+ - `naro storage cp` uploads a file up to the object limit, under 4 GiB. Over
799
+ 50 MiB it goes in parts, four at a time, and a part that fails is sent again
800
+ (up to three more times) before the upload stops. The 50 MiB refusal before
801
+ any request is gone: the worker judges the size when the upload starts, and a
802
+ file over the bucket's or the plan's limit answers `413 OBJECT_TOO_LARGE`
803
+ before a byte is sent. An upload in parts must finish within 24 hours of its
804
+ start (R2 ends unfinished multipart uploads after a day). A big upload prints its progress on stderr: one line
805
+ rewritten in place at a terminal, a line every 10% otherwise
806
+ (`↑ 312 MiB / 1.2 GiB (26%)`), and nothing with `--json`.
807
+ - Ctrl-C during an upload (or a TERM, or the terminal closing) stops it:
808
+ `Stopping the upload…` on stderr, the upload is called off — one in parts on
809
+ the backend too, so its name and the space it reserved are free at once
810
+ rather than locked for about an hour — and the run ends with the signal. A
811
+ second Ctrl-C ends it at once, without waiting for the call-off. A process
812
+ killed outright, or a network lost, still leaves the name locked until the
813
+ lock runs out.
814
+ - A file of 4 GiB or more is refused before any request
815
+ (`VALIDATION_FAILED`): Node reads its size modulo 4 GiB (4 GiB + 1 KiB reads
816
+ as 1 KiB), and the upload would send only that much and report success.
817
+ Uploading one needs a change in naro-js that is not there yet.
818
+ - A download is written to disk as it arrives, to a hidden `.naro-partial`
819
+ file that replaces the destination once it is whole, instead of being held in
820
+ memory first.
821
+ - `naro storage` says `50 MiB per request (larger files go in parts)`, and
822
+ that uploads stopped over the storage limit reopen usually within an hour and
823
+ a half: R2 reports the bucket's size 30 minutes to about an hour and a half
824
+ late.
825
+
796
826
  ### 0.15.0
797
827
 
798
828
  - `naro storage` shows the Storage meter's day between `Used:` and `Buckets:`:
799
829
  `Today (UTC):` has the write operations (R2 Class A) against the daily limit
800
830
  and the reads, `Measured:` the bucket size R2 reported and when R2 measured
801
- it (R2 reports the size 30–45 minutes late, and the meter reads it every 15
802
- minutes). A `✗` line
831
+ it (R2 reports the size 30 minutes to about an hour and a half late, and the
832
+ meter reads it every 15 minutes). A `✗` line
803
833
  says uploads are stopped, over the storage limit until objects are deleted
804
834
  or over the day's write operations until 00:00 UTC (downloads still work);
805
835
  a `⚠` line warns from 80% of the storage limit or of the day's write
@@ -872,7 +902,8 @@ An `--update-check` you give replaces the default: without
872
902
  (public or private, the largest file, the types taken), and `ls`, `cp`
873
903
  and `rm` work on `naro://<bucket>/<path>` with the project's service key
874
904
  (`NARO_SERVICE_KEY`, or fetched with a warning) — the bytes go to the
875
- project's backend, not through naro. One upload is 50 MiB at most.
905
+ project's backend, not through naro. One request carries 50 MiB at most; a
906
+ larger file goes in parts since 0.16.0 (under 4 GiB).
876
907
  `bucket update --public`, `empty`, `remove` and `rm` ask first; without a
877
908
  terminal they need `--yes` (`CONFIRMATION_REQUIRED` otherwise, nothing
878
909
  sent).
@@ -26,6 +26,7 @@ function artifactUrlKey(kind, name) {
26
26
  return `${kind}/${name}`;
27
27
  }
28
28
  var ARTIFACT_URL_BATCH_SIZE = 100;
29
+ var ARTIFACT_UPLOAD_CONCURRENCY = 8;
29
30
  function parseArtifactPutUrls(payload) {
30
31
  if (typeof payload !== "object" || payload === null) return null;
31
32
  const { urls, disabled } = payload;
@@ -37,28 +38,55 @@ function parseArtifactPutUrls(payload) {
37
38
  }
38
39
  return resolved;
39
40
  }
41
+ async function eachWithLimit(items, limit, work) {
42
+ let next = 0;
43
+ const lanes = Array.from(
44
+ { length: Math.min(limit, items.length) },
45
+ async () => {
46
+ while (next < items.length) {
47
+ const item = items[next];
48
+ next += 1;
49
+ await work(item);
50
+ }
51
+ }
52
+ );
53
+ await Promise.all(lanes);
54
+ }
40
55
  async function uploadArtifactFiles(artifact, ports) {
41
56
  const entries = artifactUploadEntries(artifact);
42
57
  let proxied = 0;
43
58
  for (let index = 0; index < entries.length; index += ARTIFACT_URL_BATCH_SIZE) {
44
59
  const batch = entries.slice(index, index + ARTIFACT_URL_BATCH_SIZE);
45
60
  const urls = await ports.resolvePutUrls(batch);
46
- for (const entry of batch) {
61
+ const viaProxy = [];
62
+ await eachWithLimit(batch, ARTIFACT_UPLOAD_CONCURRENCY, async (entry) => {
47
63
  const url2 = urls?.get(artifactUrlKey(entry.kind, entry.file.name));
48
64
  if (!url2) {
49
- proxied += 1;
50
- await ports.putProxied(entry);
51
- continue;
65
+ viaProxy.push(entry);
66
+ return;
52
67
  }
53
68
  try {
54
69
  await ports.putSigned(url2, entry);
55
70
  } catch {
56
- proxied += 1;
57
- await ports.putProxied(entry);
71
+ viaProxy.push(entry);
58
72
  }
73
+ });
74
+ for (const entry of viaProxy) {
75
+ proxied += 1;
76
+ await ports.putProxied(entry);
59
77
  }
60
78
  }
61
- return { proxied, total: entries.length };
79
+ const bytes = entries.reduce(
80
+ (total, entry) => total + entry.file.data.byteLength,
81
+ 0
82
+ );
83
+ return { proxied, total: entries.length, bytes };
84
+ }
85
+ function artifactUploadSummary(report, elapsedMs) {
86
+ const kilobytes = Math.ceil(report.bytes / 1e3);
87
+ const size = kilobytes >= 1e3 ? `${(report.bytes / 1e6).toFixed(1)} MB` : `${String(kilobytes)} kB`;
88
+ const files = report.total === 1 ? "file" : "files";
89
+ return `Uploaded ${String(report.total)} artifact ${files} (${size}) in ${(elapsedMs / 1e3).toFixed(1)} s.`;
62
90
  }
63
91
 
64
92
  // ../../packages/build/src/cache.ts
@@ -100,6 +128,7 @@ async function reportUnavailable(scope, ports, observer) {
100
128
  "Build cache is unavailable - this build runs uncached and saves nothing for the next one."
101
129
  );
102
130
  }
131
+ var UNKNOWN_PAX_KEYWORD = /^tar: Ignoring unknown extended header keyword /;
103
132
  async function restoreCache(args) {
104
133
  const { scope, key, targetDir, ports, observer } = args;
105
134
  const urls = await ports.transport.resolveUrls(scope, key);
@@ -132,7 +161,9 @@ async function restoreCache(args) {
132
161
  stage: STAGE[scope],
133
162
  errorCode: ERROR_CODE[scope]
134
163
  },
135
- observer
164
+ {
165
+ onLog: (entry) => UNKNOWN_PAX_KEYWORD.test(entry.message) ? void 0 : observer.onLog(entry)
166
+ }
136
167
  );
137
168
  await log(observer, scope, "INFO", `Restored ${scope} cache (${key}).`);
138
169
  return true;
@@ -160,7 +191,14 @@ async function saveCache(args) {
160
191
  await ports.runCommand(
161
192
  {
162
193
  command: "tar",
163
- args: ["--zstd", "-cf", archive, "-C", sourceDir, "."],
194
+ // --no-xattrs: macOS bsdtar would store extended attributes that the
195
+ // build container's GNU tar warns about per file (UNKNOWN_PAX_KEYWORD).
196
+ // GNU tar 1.27+ and bsdtar 3.x both take it.
197
+ args: ["--no-xattrs", "--zstd", "-cf", archive, "-C", sourceDir, "."],
198
+ // macOS can also write the same metadata as AppleDouble `._name` files,
199
+ // which GNU tar restores as ordinary files (PR #114 review; not seen on
200
+ // bsdtar 3.5.3 here). GNU tar ignores the variable.
201
+ env: { COPYFILE_DISABLE: "1" },
164
202
  cwd: sourceDir,
165
203
  timeoutMs: 12e4,
166
204
  stage: STAGE[scope],
@@ -257,6 +295,9 @@ var PHASES = [
257
295
  ];
258
296
  var ACTIVE_STATUSES = new Set(PHASES);
259
297
 
298
+ // ../../packages/core/src/deployment-stall.ts
299
+ var STALL_WINDOW_MS = 10 * 6e4;
300
+
260
301
  // ../../packages/core/src/deployment-strategy.ts
261
302
  var MIN_SUPPORTED_NEXT_MAJOR = 15;
262
303
 
@@ -15254,7 +15295,8 @@ function cacheEnv(dir) {
15254
15295
  return {
15255
15296
  npm_config_store_dir: dir,
15256
15297
  npm_config_cache: dir,
15257
- YARN_CACHE_FOLDER: dir
15298
+ YARN_CACHE_FOLDER: dir,
15299
+ YARN_ENABLE_GLOBAL_CACHE: "0"
15258
15300
  };
15259
15301
  }
15260
15302
  function withSharedCache(spec, dir) {
@@ -15313,7 +15355,8 @@ async function detectPackageManager(root) {
15313
15355
  "install",
15314
15356
  modern ? "--immutable" : "--frozen-lockfile"
15315
15357
  ],
15316
- // Berry takes no install-time cache flag; YARN_CACHE_FOLDER covers it.
15358
+ // Berry takes no install-time cache flag; cacheEnv covers it
15359
+ // (YARN_CACHE_FOLDER with the global cache switched off).
15317
15360
  cacheArgs: (dir) => modern ? [] : ["--cache-folder", dir],
15318
15361
  addArgs: (specs) => ["yarn", "add", ...specs]
15319
15362
  };
@@ -18446,22 +18489,31 @@ function createLogBatcher(options) {
18446
18489
  let buffer = [];
18447
18490
  let timer = null;
18448
18491
  let pending = null;
18492
+ let sending = Promise.resolve();
18449
18493
  function disarm() {
18450
18494
  if (timer === null) return;
18451
18495
  clearTimeout(timer);
18452
18496
  timer = null;
18453
18497
  }
18498
+ function surfacePending() {
18499
+ if (pending === null) return;
18500
+ const { error: error51 } = pending;
18501
+ pending = null;
18502
+ throw error51;
18503
+ }
18454
18504
  async function flush() {
18455
18505
  disarm();
18456
- if (pending !== null) {
18457
- const { error: error51 } = pending;
18458
- pending = null;
18459
- throw error51;
18506
+ surfacePending();
18507
+ if (buffer.length === 0) {
18508
+ await sending;
18509
+ surfacePending();
18510
+ return;
18460
18511
  }
18461
- if (buffer.length === 0) return;
18462
18512
  const batch = buffer;
18463
18513
  buffer = [];
18464
- await options.send(batch);
18514
+ const send = sending.then(() => options.send(batch));
18515
+ sending = send.catch(() => void 0);
18516
+ await send;
18465
18517
  }
18466
18518
  function arm() {
18467
18519
  if (timer !== null || !options.intervalMs) return;
@@ -21080,6 +21132,10 @@ var CATALOG = {
21080
21132
  status: 404,
21081
21133
  message: ({ bucket, path }) => `Object ${storageName(path)} was not found in bucket ${storageName(bucket)}.`
21082
21134
  },
21135
+ UPLOAD_NOT_FOUND: {
21136
+ status: 404,
21137
+ message: ({ upload }) => `Upload ${storageName(upload)} was not found or has expired.`
21138
+ },
21083
21139
  INVALID_CONTENT_TYPE: {
21084
21140
  status: 400,
21085
21141
  message: ({ type }) => `Content-Type ${storageName(type)} is not a media type like image/png.`
@@ -21094,7 +21150,7 @@ var CATALOG = {
21094
21150
  },
21095
21151
  OBJECT_TOO_LARGE: {
21096
21152
  status: 413,
21097
- message: (vars) => "single" in vars ? "Uploads over 50 MiB are not supported yet." : `The object is ${String(vars.size)} bytes; bucket ${storageName(vars.bucket)} takes objects up to ${String(vars.max)} bytes.`
21153
+ message: (vars) => "single" in vars ? "Uploads over 50 MiB go in parts: use the multipart upload." : `The object is ${String(vars.size)} bytes; bucket ${storageName(vars.bucket)} takes objects up to ${String(vars.max)} bytes.`
21098
21154
  },
21099
21155
  MIME_TYPE_NOT_ALLOWED: {
21100
21156
  status: 415,
@@ -25840,7 +25896,7 @@ function claimUpload(i) {
25840
25896
  const nonce = nonceOf(i.nonce);
25841
25897
  return compose(
25842
25898
  [
25843
- `INSERT INTO ${STORAGE_OBJECTS_TABLE} (id, bucket_id, name, owner, owner_id, metadata, version, parent, size, status, lock_id, lock_expires_at) SELECT ?, ?, ?, ?, ?, ?, NULL, ?, ?, 'uploading', ?, ? WHERE CASE WHEN NOT EXISTS (SELECT 1 FROM ${STORAGE_BUCKETS_TABLE} WHERE id = ? AND ${LIVE_BUCKET}) THEN ${marker("bucket", nonce)} WHEN (`,
25899
+ `INSERT INTO ${STORAGE_OBJECTS_TABLE} (id, bucket_id, name, owner, owner_id, metadata, version, parent, size, status, lock_id, lock_expires_at, pending) SELECT ?, ?, ?, ?, ?, ?, NULL, ?, ?, 'uploading', ?, ?, ? WHERE CASE WHEN NOT EXISTS (SELECT 1 FROM ${STORAGE_BUCKETS_TABLE} WHERE id = ? AND ${LIVE_BUCKET}) THEN ${marker("bucket", nonce)} WHEN (`,
25844
25900
  i.check,
25845
25901
  `) <> 1 THEN 0 WHEN EXISTS (SELECT 1 FROM ${STORAGE_OBJECTS_TABLE} WHERE bucket_id = ? AND name = ? AND status = 'ready') THEN ${marker("exists", nonce)} WHEN EXISTS (SELECT 1 FROM ${STORAGE_OBJECTS_TABLE} WHERE bucket_id = ? AND name = ?) THEN ${marker("busy", nonce)} WHEN ${PROJECT_BYTES} + ? > ? THEN ${marker("quota", nonce)} ELSE 1 END RETURNING id`
25846
25902
  ],
@@ -25856,6 +25912,7 @@ function claimUpload(i) {
25856
25912
  i.size,
25857
25913
  i.lockId,
25858
25914
  i.expiresAt,
25915
+ i.pending ?? null,
25859
25916
  i.bucket
25860
25917
  ],
25861
25918
  [i.bucket, i.name, i.bucket, i.name, i.size, i.limitBytes]
@@ -25879,6 +25936,13 @@ function overwriteCheckRow(i) {
25879
25936
  }
25880
25937
  function claimOverwrite(i) {
25881
25938
  const nonce = nonceOf(i.nonce);
25939
+ const room = i.inParts ? {
25940
+ sql: `${PROJECT_BYTES} + ? > ?`,
25941
+ values: [i.size, i.limitBytes]
25942
+ } : {
25943
+ sql: `? > o.size AND ${PROJECT_BYTES} + ? - o.size > ?`,
25944
+ values: [i.size, i.size, i.limitBytes]
25945
+ };
25882
25946
  return compose(
25883
25947
  [
25884
25948
  `UPDATE ${STORAGE_OBJECTS_TABLE} AS o SET lock_id = ?, lock_expires_at = ?, pending = ? WHERE o.bucket_id = ? AND o.name = ? AND CASE WHEN NOT (`,
@@ -25886,17 +25950,14 @@ function claimOverwrite(i) {
25886
25950
  // The row's own bucket (`o.bucket_id`), so no value is bound for it.
25887
25951
  `) THEN ${marker("hidden", nonce)} WHEN NOT EXISTS (SELECT 1 FROM ${STORAGE_BUCKETS_TABLE} AS b WHERE b.id = o.bucket_id AND b.${LIVE_BUCKET}) THEN ${marker("bucket", nonce)} WHEN o.status <> 'ready' OR o.lock_id IS NOT NULL THEN ${marker("busy", nonce)} WHEN NOT (`,
25888
25952
  i.updateUsing,
25889
- `) THEN ${marker("denied", nonce)} WHEN ? > o.size AND ${PROJECT_BYTES} + ? - o.size > ? THEN ${marker("quota", nonce)} ELSE (`,
25953
+ `) THEN ${marker("denied", nonce)} WHEN ${room.sql} THEN ${marker("quota", nonce)} ELSE (`,
25890
25954
  i.check,
25891
25955
  // RETURNING names the columns bare: it does not see the UPDATE's
25892
25956
  // alias (SQLite 3.53: `no such column: o.id`), and the table is the
25893
25957
  // only one there.
25894
25958
  ") END RETURNING id, version, size, owner, owner_id, created_at"
25895
25959
  ],
25896
- [
25897
- [i.lockId, i.expiresAt, i.pending, i.bucket, i.name],
25898
- [i.size, i.size, i.limitBytes]
25899
- ]
25960
+ [[i.lockId, i.expiresAt, i.pending, i.bucket, i.name], room.values]
25900
25961
  );
25901
25962
  }
25902
25963
  function claimDelete(i) {
@@ -27075,6 +27136,17 @@ function formatRedirectEntry(entry) {
27075
27136
  return entry.path === "/" ? entry.origin : `${entry.origin}${entry.path}`;
27076
27137
  }
27077
27138
 
27139
+ // ../../packages/data-core/src/storage-multipart.ts
27140
+ var MiB = 1024 * 1024;
27141
+ var MULTIPART_PART_SIZES = [16 * MiB, 32 * MiB, 64 * MiB];
27142
+ var MAX_MULTIPART_PARTS = 1e4;
27143
+ var MAX_MULTIPART_BYTES = 64 * MiB * MAX_MULTIPART_PARTS;
27144
+ var MULTIPART_LOCK_MS = 60 * 60 * 1e3;
27145
+ var MULTIPART_PART_EXTEND_BELOW_MS = 30 * 60 * 1e3;
27146
+ var MULTIPART_COMPLETE_LOCK_MS = 15 * 60 * 1e3;
27147
+ var UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
27148
+ var UPLOAD_ID = new RegExp(`^(${UUID})\\.(${UUID})$`);
27149
+
27078
27150
  // ../../packages/data-core/src/system-sql.ts
27079
27151
  async function convertLegacyPolicies(db, found) {
27080
27152
  const rows = rowsOf(await db.prepare(POLICY_SQL.list).all());
@@ -28203,7 +28275,9 @@ function manifest(artifact) {
28203
28275
  };
28204
28276
  }
28205
28277
  async function uploadArtifact(artifact, opts) {
28206
- await uploadArtifactFiles(artifact, uploadPorts(opts));
28278
+ const started = Date.now();
28279
+ const report = await uploadArtifactFiles(artifact, uploadPorts(opts));
28280
+ const uploadSummary = artifactUploadSummary(report, Date.now() - started);
28207
28281
  const response = await authed(opts, "/artifacts/complete", {
28208
28282
  method: "POST",
28209
28283
  headers: { "Content-Type": "application/json" },
@@ -28214,7 +28288,8 @@ async function uploadArtifact(artifact, opts) {
28214
28288
  throw new Error("Control plane did not return an artifact key.");
28215
28289
  return {
28216
28290
  artifactKey: payload.artifactKey,
28217
- previewUrl: typeof payload.previewUrl === "string" ? payload.previewUrl : null
28291
+ previewUrl: typeof payload.previewUrl === "string" ? payload.previewUrl : null,
28292
+ uploadSummary
28218
28293
  };
28219
28294
  }
28220
28295
 
@@ -28290,7 +28365,8 @@ function eventsObserver(cp3, deploymentId, token, fetchImpl = fetch, logIngestUr
28290
28365
  await flushLogs();
28291
28366
  await post({ type: "metadata", metadata: rest }).catch(() => {
28292
28367
  });
28293
- }
28368
+ },
28369
+ flush: flushLogs
28294
28370
  };
28295
28371
  }
28296
28372
  function slugify2(value) {
@@ -28485,12 +28561,19 @@ async function deployDirectory(sourceDir, options, log2) {
28485
28561
  { details: { deploymentId: ctx.deploymentId }, cause: error51 }
28486
28562
  );
28487
28563
  }
28488
- await uploadArtifact(artifact, {
28564
+ const { uploadSummary } = await uploadArtifact(artifact, {
28489
28565
  controlPlaneUrl: cpUrl,
28490
28566
  deploymentId: ctx.deploymentId,
28491
28567
  callbackToken: ctx.callbackToken,
28492
28568
  ...options.fetch ? { fetch: options.fetch } : {}
28493
28569
  });
28570
+ log2(`\u25B8 ${uploadSummary}`);
28571
+ await observer.onLog({
28572
+ level: "INFO",
28573
+ stage: "DEPLOYING",
28574
+ message: uploadSummary
28575
+ });
28576
+ await observer.flush();
28494
28577
  log2("\u25B8 publishing worker");
28495
28578
  const detail = await waitForFinish(client, ctx, log2);
28496
28579
  const url2 = deploymentUrl(detail, ctx, production);
@@ -28568,7 +28651,6 @@ export {
28568
28651
  parseCacheControl,
28569
28652
  STORAGE_OBJECTS_TABLE,
28570
28653
  systemTablePredicate,
28571
- dataError,
28572
28654
  SCHEMA_SQL,
28573
28655
  tableNames,
28574
28656
  snapshotFromRows,
package/dist/deploy.d.ts CHANGED
@@ -21,7 +21,10 @@ export interface DeployBuildExecutor {
21
21
  export declare function parseDotenv(text: string): Record<string, string>;
22
22
  /** Reads the first of .env.local / .env.production / .env in a directory. */
23
23
  export declare function readProjectEnv(dir: string): Record<string, string>;
24
- export declare function eventsObserver(cp: string, deploymentId: string, token: string, fetchImpl?: typeof fetch, logIngestUrl?: string): BuildObserver;
24
+ export declare function eventsObserver(cp: string, deploymentId: string, token: string, fetchImpl?: typeof fetch, logIngestUrl?: string): BuildObserver & {
25
+ /** Sends what is buffered now; never throws. */
26
+ flush(): Promise<void>;
27
+ };
25
28
  export declare function slugify(value: string): string;
26
29
  export interface DeployOptions {
27
30
  name?: string | undefined;
package/dist/deploy.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseDotenv,
6
6
  readProjectEnv,
7
7
  slugify
8
- } from "./chunk-6WJ3QQ5Y.js";
8
+ } from "./chunk-XNT7SINN.js";
9
9
  import "./chunk-K3NQKI34.js";
10
10
  export {
11
11
  deployDirectory,
package/dist/index.js CHANGED
@@ -26,7 +26,6 @@ import {
26
26
  controlPlaneUrl,
27
27
  countLintedTables,
28
28
  createAuthClient,
29
- dataError,
30
29
  deployDirectory,
31
30
  detectGitInfo,
32
31
  ensureGitignoreHasNaro,
@@ -83,7 +82,7 @@ import {
83
82
  toNaroError,
84
83
  unreadablePolicyComment,
85
84
  writeProjectLink
86
- } from "./chunk-6WJ3QQ5Y.js";
85
+ } from "./chunk-XNT7SINN.js";
87
86
  import "./chunk-K3NQKI34.js";
88
87
 
89
88
  // src/args.ts
@@ -289,6 +288,13 @@ var Output = class {
289
288
  this.streams.stderr(`${message}
290
289
  `);
291
290
  }
291
+ /**
292
+ * Text on stderr exactly as given, with no line break added: a progress
293
+ * line a terminal rewrites in place (`\r…`). Never stdout, in either mode.
294
+ */
295
+ progress(text) {
296
+ this.streams.stderr(text);
297
+ }
292
298
  /** Extra diagnostics, only with --debug. */
293
299
  debugLog(message) {
294
300
  if (this.debug) this.streams.stderr(`[debug] ${message}
@@ -5337,7 +5343,7 @@ function meterLines(info) {
5337
5343
  }
5338
5344
  if (today.bytesBlocked)
5339
5345
  lines.push(
5340
- today.measuredBytes === null ? `\u2717 Uploads are stopped: the bucket is at the storage limit of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour).` : `\u2717 Uploads are stopped: R2 holds ${binarySize(today.measuredBytes, 2)} of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour).`
5346
+ today.measuredBytes === null ? `\u2717 Uploads are stopped: the bucket is at the storage limit of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour and a half).` : `\u2717 Uploads are stopped: R2 holds ${binarySize(today.measuredBytes, 2)} of ${binarySize(limits.bytes)}. Delete objects; uploads reopen after the next measurement (usually within an hour and a half).`
5341
5347
  );
5342
5348
  if (today.opsBlocked)
5343
5349
  lines.push(
@@ -5362,7 +5368,7 @@ function storageLines(info) {
5362
5368
  const { limits, usage } = info;
5363
5369
  return [
5364
5370
  `Storage: on (R2 bucket ${printable(info.bucket ?? "unknown")})`,
5365
- `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per upload`,
5371
+ `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per request (larger files go in parts)`,
5366
5372
  usage === null ? "Used: not known yet: the backend is not ready (`naro backend` shows where it got to)" : `Used: ${binarySize(usage.bytes)} in ${count(usage.objects, "object")}`,
5367
5373
  ...meterLines(info),
5368
5374
  info.buckets.length === 0 ? "Buckets: none yet (`naro storage bucket create <name>` makes one)" : `Buckets: ${String(info.buckets.length)}`,
@@ -5549,6 +5555,12 @@ function requestTimeout(ms) {
5549
5555
  message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
5550
5556
  };
5551
5557
  }
5558
+ function uploadAborted() {
5559
+ return {
5560
+ code: "ABORTED",
5561
+ message: "The upload was stopped before it finished."
5562
+ };
5563
+ }
5552
5564
  function invalidResponse(status) {
5553
5565
  return {
5554
5566
  code: "INVALID_RESPONSE",
@@ -5714,6 +5726,12 @@ async function send(config, options) {
5714
5726
  credentials: "omit"
5715
5727
  };
5716
5728
  if (options.body !== void 0) init.body = options.body;
5729
+ const { signal } = options;
5730
+ if (signal?.aborted) return { error: uploadAborted(), status: null };
5731
+ const controller = signal ? new AbortController() : null;
5732
+ const stop = () => controller?.abort();
5733
+ if (controller) init.signal = controller.signal;
5734
+ signal?.addEventListener("abort", stop);
5717
5735
  let response;
5718
5736
  try {
5719
5737
  const fetchImpl = config.fetch ?? globalThis.fetch;
@@ -5722,26 +5740,34 @@ async function send(config, options) {
5722
5740
  init
5723
5741
  );
5724
5742
  } catch (error2) {
5725
- return { error: networkError(error2) };
5743
+ return {
5744
+ error: signal?.aborted ? uploadAborted() : networkError(error2),
5745
+ status: null
5746
+ };
5747
+ } finally {
5748
+ signal?.removeEventListener("abort", stop);
5726
5749
  }
5727
- if (response.status >= 200 && response.status < 300)
5728
- return { error: null, response };
5750
+ const { status } = response;
5751
+ if (status >= 200 && status < 300) return { error: null, response };
5729
5752
  let text;
5730
5753
  try {
5731
5754
  text = await response.text();
5732
5755
  } catch (error2) {
5733
- return { error: networkError(error2) };
5756
+ return { error: networkError(error2), status: null };
5734
5757
  }
5735
5758
  let body;
5736
5759
  if (text.trim() !== "") {
5737
5760
  try {
5738
5761
  body = JSON.parse(text);
5739
5762
  } catch {
5740
- return { error: invalidResponse(response.status) };
5763
+ return { error: invalidResponse(status), status };
5741
5764
  }
5742
5765
  }
5743
- const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
5744
- return { error: await settled(config, error, response.headers, token) };
5766
+ const error = errorFromBody(status, body) ?? invalidResponse(status);
5767
+ return {
5768
+ error: await settled(config, error, response.headers, token),
5769
+ status
5770
+ };
5745
5771
  }
5746
5772
  var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
5747
5773
  "RATE_LIMITED",
@@ -7312,10 +7338,176 @@ var TableBuilder = class {
7312
7338
  });
7313
7339
  }
7314
7340
  };
7341
+ function fail(error) {
7342
+ return { data: null, error };
7343
+ }
7344
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7345
+ function dataOf2(status, body, guard) {
7346
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7347
+ return { data: body.data, error: null };
7348
+ return fail(invalidResponse(status));
7349
+ }
7350
+ async function readJson(response, guard) {
7351
+ let text;
7352
+ try {
7353
+ text = await response.text();
7354
+ } catch (error) {
7355
+ return fail(networkError(error));
7356
+ }
7357
+ let body;
7358
+ try {
7359
+ body = JSON.parse(text);
7360
+ } catch {
7361
+ return fail(invalidResponse(response.status));
7362
+ }
7363
+ return dataOf2(response.status, body, guard);
7364
+ }
7365
+ var ROUTE = "/storage/upload/multipart";
7366
+ var CONCURRENCY = 4;
7367
+ var ATTEMPTS = 4;
7368
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
7369
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
7370
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
7371
+ var planFor = (size) => (value) => isRecord(value) && typeof value.uploadId === "string" && UPLOAD_ID.test(value.uploadId) && positive(value.partSize) && positive(value.partCount) && (value.partCount - 1) * value.partSize < size && value.partCount * value.partSize >= size;
7372
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
7373
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
7374
+ function waitAfter(error, attempt22) {
7375
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
7376
+ if (typeof after === "number") return after * 1e3;
7377
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
7378
+ return BACKOFF_MS[attempt22 - 1] ?? 4e3;
7379
+ }
7380
+ var sleepFor = (ms, signal) => new Promise((done) => {
7381
+ if (signal?.aborted) {
7382
+ done();
7383
+ return;
7384
+ }
7385
+ const end = () => {
7386
+ clearTimeout(timer);
7387
+ signal?.removeEventListener("abort", end);
7388
+ done();
7389
+ };
7390
+ const timer = setTimeout(end, ms);
7391
+ signal?.addEventListener("abort", end);
7392
+ });
7393
+ async function uploadInParts(a) {
7394
+ const { signal } = a;
7395
+ const sleep2 = a.sleep ?? sleepFor;
7396
+ if (signal?.aborted) return fail(uploadAborted());
7397
+ const started = await call(a.http, {
7398
+ method: a.method,
7399
+ path: ROUTE,
7400
+ body: {
7401
+ bucket: a.bucket,
7402
+ path: a.path,
7403
+ size: a.size,
7404
+ contentType: a.contentType,
7405
+ cacheControl: a.cacheControl,
7406
+ // PUT always replaces: the worker refuses `upsert` on it.
7407
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
7408
+ },
7409
+ withToken: true
7410
+ });
7411
+ if (started.error !== null) return fail(started.error);
7412
+ const plan = dataOf2(
7413
+ started.answer.status,
7414
+ started.answer.body,
7415
+ planFor(a.size)
7416
+ );
7417
+ if (plan.error !== null) return fail(plan.error);
7418
+ const { uploadId, partSize, partCount } = plan.data;
7419
+ const base = `${ROUTE}/${uploadId}`;
7420
+ const progress = (sentBytes2) => {
7421
+ if (signal?.aborted) return;
7422
+ try {
7423
+ const result = a.onProgress?.(sentBytes2, a.size);
7424
+ if (isThenable(result)) result.then(void 0, () => void 0);
7425
+ } catch {
7426
+ }
7427
+ };
7428
+ const callOff = async () => {
7429
+ await call(a.http, { method: "DELETE", path: base, withToken: true }).catch(
7430
+ () => void 0
7431
+ );
7432
+ };
7433
+ const etags = [];
7434
+ const failure = {
7435
+ error: signal?.aborted ? uploadAborted() : null
7436
+ };
7437
+ let next = 1;
7438
+ let sentBytes = 0;
7439
+ const onAbort = () => {
7440
+ failure.error ??= uploadAborted();
7441
+ };
7442
+ const tryPart = async (n, bytes) => {
7443
+ const sent = await send(a.http, {
7444
+ method: "PUT",
7445
+ path: `${base}/${String(n)}`,
7446
+ headers: { "content-type": "application/octet-stream" },
7447
+ body: bytes,
7448
+ withToken: true,
7449
+ signal
7450
+ });
7451
+ if (sent.error !== null) return sent;
7452
+ const read = await readJson(sent.response, partAnswer(n));
7453
+ if (read.error !== null)
7454
+ return { error: read.error, status: sent.response.status };
7455
+ return { etag: read.data.etag };
7456
+ };
7457
+ const sendPart = async (n) => {
7458
+ const from = (n - 1) * partSize;
7459
+ const to = Math.min(from + partSize, a.size);
7460
+ const bytes = a.body instanceof Uint8Array ? a.body.subarray(from, to) : a.body.slice(from, to);
7461
+ for (let attempt22 = 1; ; attempt22 += 1) {
7462
+ const tried = await tryPart(n, bytes);
7463
+ if ("etag" in tried) {
7464
+ etags[n - 1] = tried.etag;
7465
+ sentBytes += to - from;
7466
+ progress(sentBytes);
7467
+ return null;
7468
+ }
7469
+ if (attempt22 === ATTEMPTS || failure.error !== null || !retryable(tried.error, tried.status))
7470
+ return tried.error;
7471
+ await sleep2(waitAfter(tried.error, attempt22), signal);
7472
+ if (failure.error !== null) return tried.error;
7473
+ }
7474
+ };
7475
+ const worker = async () => {
7476
+ while (failure.error === null && next <= partCount) {
7477
+ const n = next;
7478
+ next += 1;
7479
+ const error = await sendPart(n);
7480
+ if (error !== null) failure.error ??= error;
7481
+ }
7482
+ };
7483
+ signal?.addEventListener("abort", onAbort);
7484
+ await Promise.all(
7485
+ Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker)
7486
+ );
7487
+ signal?.removeEventListener("abort", onAbort);
7488
+ if (failure.error !== null) {
7489
+ await callOff();
7490
+ return fail(failure.error);
7491
+ }
7492
+ const completed = await call(a.http, {
7493
+ method: "POST",
7494
+ path: `${base}/complete`,
7495
+ body: {
7496
+ parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
7497
+ },
7498
+ withToken: true
7499
+ });
7500
+ if (completed.error !== null) {
7501
+ if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
7502
+ await callOff();
7503
+ return fail(completed.error);
7504
+ }
7505
+ return dataOf2(completed.answer.status, completed.answer.body, isUploadResult);
7506
+ }
7507
+ var isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
7315
7508
  var MAX_SINGLE_UPLOAD_BYTES2 = 52428800;
7316
7509
  var STORAGE_MESSAGES = {
7317
7510
  bucket: "The bucket name is not valid.",
7318
- tooLarge: "Uploads over 50 MiB are not supported yet.",
7319
7511
  request: "The storage request is not valid.",
7320
7512
  body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
7321
7513
  };
@@ -7340,9 +7532,6 @@ function invalidPath(reason) {
7340
7532
  message: `The object path is not valid: it ${reason}.`
7341
7533
  };
7342
7534
  }
7343
- function objectTooLarge() {
7344
- return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
7345
- }
7346
7535
  function encodePath(path) {
7347
7536
  if (typeof path !== "string" || path === "")
7348
7537
  return { ok: false, error: invalidPath("is empty") };
@@ -7379,6 +7568,16 @@ function encodeBucket(bucket) {
7379
7568
  };
7380
7569
  return { ok: true, encoded: encodeURIComponent(bucket) };
7381
7570
  }
7571
+ function objectRoute(bucket, path) {
7572
+ const encodedBucket = encodeBucket(bucket);
7573
+ if (!encodedBucket.ok) return encodedBucket;
7574
+ const encodedPath = encodePath(path);
7575
+ if (!encodedPath.ok) return encodedPath;
7576
+ return {
7577
+ ok: true,
7578
+ encoded: `${encodedBucket.encoded}/${encodedPath.encoded}`
7579
+ };
7580
+ }
7382
7581
  var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
7383
7582
  function isDetached(buffer) {
7384
7583
  if (!isArrayBuffer(buffer)) return false;
@@ -7459,36 +7658,12 @@ function payloadOf(body, contentType) {
7459
7658
  };
7460
7659
  }
7461
7660
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
7462
- function fail(error) {
7463
- return { data: null, error };
7464
- }
7465
- var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7466
7661
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
7467
7662
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
7468
7663
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
7469
7664
  function listOf(guard) {
7470
7665
  return (value) => Array.isArray(value) && value.every(guard);
7471
7666
  }
7472
- function dataOf2(status, body, guard) {
7473
- if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7474
- return { data: body.data, error: null };
7475
- return fail(invalidResponse(status));
7476
- }
7477
- async function readJson(response, guard) {
7478
- let text;
7479
- try {
7480
- text = await response.text();
7481
- } catch (error) {
7482
- return fail(networkError(error));
7483
- }
7484
- let body;
7485
- try {
7486
- body = JSON.parse(text);
7487
- } catch {
7488
- return fail(invalidResponse(response.status));
7489
- }
7490
- return dataOf2(response.status, body, guard);
7491
- }
7492
7667
  var StorageFileApi = class {
7493
7668
  #http;
7494
7669
  #auth;
@@ -7500,14 +7675,9 @@ var StorageFileApi = class {
7500
7675
  }
7501
7676
  /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
7502
7677
  #objectPath(route2, path) {
7503
- const bucket = encodeBucket(this.#bucket);
7504
- if (!bucket.ok) return bucket;
7505
- const name = encodePath(path);
7506
- if (!name.ok) return name;
7507
- return {
7508
- ok: true,
7509
- path: `/storage/object/${route2}${bucket.encoded}/${name.encoded}`
7510
- };
7678
+ const target = objectRoute(this.#bucket, path);
7679
+ if (!target.ok) return target;
7680
+ return { ok: true, path: `/storage/object/${route2}${target.encoded}` };
7511
7681
  }
7512
7682
  /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
7513
7683
  #bucketPath(route2) {
@@ -7529,8 +7699,24 @@ var StorageFileApi = class {
7529
7699
  const cache = `max-age=${options.cacheControl ?? "3600"}`;
7530
7700
  const unreadable = headerProblem(options.contentType, cache);
7531
7701
  if (unreadable !== null) return fail(unreadable);
7532
- if (payload.size > MAX_SINGLE_UPLOAD_BYTES2) return fail(objectTooLarge());
7702
+ const { signal } = options;
7703
+ if (signal?.aborted) return fail(uploadAborted());
7533
7704
  await this.#auth.ready();
7705
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES2)
7706
+ return uploadInParts({
7707
+ http: this.#http,
7708
+ method,
7709
+ bucket: this.#bucket,
7710
+ path,
7711
+ // No copy: a Blob stays lazy, a Uint8Array is viewed, an ArrayBuffer wrapped.
7712
+ body: isArrayBuffer(payload.body) ? new Uint8Array(payload.body) : payload.body,
7713
+ size: payload.size,
7714
+ contentType: payload.type,
7715
+ cacheControl: cache,
7716
+ upsert,
7717
+ ...options.onProgress ? { onProgress: options.onProgress } : {},
7718
+ signal
7719
+ });
7534
7720
  const headers = {
7535
7721
  "content-type": payload.type,
7536
7722
  "cache-control": cache
@@ -7541,7 +7727,9 @@ var StorageFileApi = class {
7541
7727
  path: target.path,
7542
7728
  headers,
7543
7729
  body: payload.body,
7544
- withToken: true
7730
+ withToken: true,
7731
+ // The fetch stops with the body: the worker undoes its claim at once.
7732
+ signal
7545
7733
  });
7546
7734
  if (sent.error !== null) return fail(sent.error);
7547
7735
  return readJson(sent.response, isUploadResult);
@@ -7787,6 +7975,7 @@ var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<pa
7787
7975
  var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
7788
7976
  var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]";
7789
7977
  var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
7978
+ var FOUR_GIB = 4294967296;
7790
7979
  var PAGE = 1e3;
7791
7980
  var MAX_OFFSET = 1e5;
7792
7981
  var TRUNCATED_WARNING = `\u26A0 A folder's listing stopped at ${(MAX_OFFSET + PAGE).toLocaleString("en-US")} entries, as far as the API's offset reaches; there may be more.`;
@@ -7889,7 +8078,8 @@ async function objects(context) {
7889
8078
  { cause: error }
7890
8079
  );
7891
8080
  }
7892
- return { projectId, storage: naro.storage };
8081
+ const url = new URL(backend.url.trim().replace(/\/+$/, "")).origin;
8082
+ return { projectId, storage: naro.storage, url, serviceKey };
7893
8083
  }
7894
8084
  function isFolder(entry) {
7895
8085
  return entry.id === null;
@@ -7982,33 +8172,56 @@ async function storageLs(context) {
7982
8172
  );
7983
8173
  }
7984
8174
  var INTERRUPTS = ["SIGINT", "SIGTERM", "SIGHUP"];
7985
- function removeOnInterrupt(partial) {
7986
- const stop = () => {
8175
+ function endWith(signal) {
8176
+ try {
8177
+ process.kill(process.pid, signal);
8178
+ } catch {
8179
+ process.exit(1);
8180
+ }
8181
+ }
8182
+ function onInterrupt(onSignal) {
8183
+ for (const signal of INTERRUPTS) process.on(signal, onSignal);
8184
+ return () => {
7987
8185
  for (const signal of INTERRUPTS) process.off(signal, onSignal);
7988
8186
  };
7989
- function onSignal(signal) {
8187
+ }
8188
+ function removeOnInterrupt(partial) {
8189
+ const stop = onInterrupt((signal) => {
7990
8190
  stop();
7991
8191
  try {
7992
8192
  rmSync2(partial, { force: true });
7993
8193
  } catch {
7994
8194
  }
7995
- try {
7996
- process.kill(process.pid, signal);
7997
- } catch {
7998
- process.exit(1);
7999
- }
8000
- }
8001
- for (const signal of INTERRUPTS) process.on(signal, onSignal);
8195
+ endWith(signal);
8196
+ });
8002
8197
  return stop;
8003
8198
  }
8004
- async function writeAtomically(blob, destination) {
8199
+ function stopOnInterrupt(onFirst) {
8200
+ const controller = new AbortController();
8201
+ let caught = null;
8202
+ const stop = onInterrupt((signal) => {
8203
+ if (caught !== null) {
8204
+ stop();
8205
+ endWith(signal);
8206
+ return;
8207
+ }
8208
+ caught = signal;
8209
+ onFirst();
8210
+ controller.abort();
8211
+ });
8212
+ return {
8213
+ signal: controller.signal,
8214
+ settle: () => {
8215
+ stop();
8216
+ return caught;
8217
+ }
8218
+ };
8219
+ }
8220
+ async function writeAtomically(bytes, destination) {
8005
8221
  const partial = join6(dirname3(destination), `.${randomUUID()}.naro-partial`);
8006
8222
  const stopListening = removeOnInterrupt(partial);
8007
8223
  try {
8008
- await pipeline2(
8009
- Readable.fromWeb(blob.stream()),
8010
- createWriteStream2(partial, { flags: "wx" })
8011
- );
8224
+ await pipeline2(bytes, createWriteStream2(partial, { flags: "wx" }));
8012
8225
  await rename(partial, destination);
8013
8226
  } catch (error) {
8014
8227
  await rm(partial, { force: true }).catch(() => void 0);
@@ -8017,6 +8230,34 @@ async function writeAtomically(blob, destination) {
8017
8230
  stopListening();
8018
8231
  }
8019
8232
  }
8233
+ function progressReporter(output, total) {
8234
+ const tty = process.stderr.isTTY === true;
8235
+ let lastTenth = -1;
8236
+ let lastLength = 0;
8237
+ let open = false;
8238
+ return {
8239
+ report(sent) {
8240
+ const percent2 = Math.floor(sent / total * 100);
8241
+ const line = `\u2191 ${binarySize(sent)} / ${binarySize(total)} (${String(percent2)}%)`;
8242
+ if (tty) {
8243
+ open = sent !== total;
8244
+ output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
8245
+ lastLength = line.length;
8246
+ return;
8247
+ }
8248
+ const tenth = Math.floor(percent2 / 10);
8249
+ if (tenth === lastTenth && sent !== total) return;
8250
+ lastTenth = tenth;
8251
+ output.progress(`${line}
8252
+ `);
8253
+ },
8254
+ close() {
8255
+ if (!open) return;
8256
+ open = false;
8257
+ output.progress("\n");
8258
+ }
8259
+ };
8260
+ }
8020
8261
  async function upload(context, local, target) {
8021
8262
  const file = resolve5(context.cwd, local);
8022
8263
  const info = await stat(file).catch(() => null);
@@ -8035,32 +8276,49 @@ async function upload(context, local, target) {
8035
8276
  "VALIDATION_FAILED",
8036
8277
  `${printable(local)} is not a regular file.`
8037
8278
  );
8038
- if (info.size > MAX_SINGLE_UPLOAD_BYTES)
8039
- throw new NaroError(
8040
- "OBJECT_TOO_LARGE",
8041
- dataError("OBJECT_TOO_LARGE", { single: true }).message
8042
- );
8043
8279
  const { cacheControl } = context.flags;
8044
8280
  if (cacheControl !== void 0 && !isMaxAge(cacheControl))
8045
8281
  throw new NaroError("VALIDATION_FAILED", CACHE_CONTROL_RULE);
8046
8282
  const path = target.path === "" || target.path.endsWith("/") ? `${target.path}${basename2(file)}` : target.path;
8047
- const { projectId, storage } = await objects(context);
8048
8283
  const body = await openAsBlob(file);
8284
+ if (body.size !== info.size)
8285
+ throw new NaroError(
8286
+ "VALIDATION_FAILED",
8287
+ info.size >= FOUR_GIB ? `${printable(local)} is ${binarySize(info.size)}, but Node reads it as ${binarySize(body.size)}: naro storage cp cannot upload a file of 4 GiB or more yet.` : `${printable(local)} changed while it was being opened (${binarySize(info.size)}, then ${binarySize(body.size)}). Run the copy again once nothing is writing to it.`
8288
+ );
8289
+ const { projectId, storage } = await objects(context);
8049
8290
  const contentType = context.flags.contentType ?? typeFor(file);
8050
- const result = unwrap(
8051
- await storage.from(target.bucket).upload(path, body, {
8291
+ const progress = body.size > MAX_SINGLE_UPLOAD_BYTES && !context.output.json ? progressReporter(context.output, body.size) : void 0;
8292
+ const interrupt = stopOnInterrupt(() => {
8293
+ progress?.close();
8294
+ context.output.progress("Stopping the upload\u2026\n");
8295
+ });
8296
+ let answer;
8297
+ let caught;
8298
+ try {
8299
+ answer = await storage.from(target.bucket).upload(path, body, {
8052
8300
  ...contentType === void 0 ? {} : { contentType },
8053
8301
  ...cacheControl === void 0 ? {} : { cacheControl },
8054
- upsert: context.flags.upsert === true
8055
- })
8056
- );
8057
- const to = `${NARO_SCHEME}${target.bucket}/${path}`;
8058
- context.output.result(
8059
- { projectId, from: local, to, bytes: body.size, id: result.id },
8060
- [
8061
- `\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
8062
- ]
8063
- );
8302
+ upsert: context.flags.upsert === true,
8303
+ ...progress === void 0 ? {} : { onProgress: progress.report },
8304
+ signal: interrupt.signal
8305
+ });
8306
+ } finally {
8307
+ progress?.close();
8308
+ caught = interrupt.settle();
8309
+ }
8310
+ try {
8311
+ const result = unwrap(answer);
8312
+ const to = `${NARO_SCHEME}${target.bucket}/${path}`;
8313
+ context.output.result(
8314
+ { projectId, from: local, to, bytes: body.size, id: result.id },
8315
+ [
8316
+ `\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
8317
+ ]
8318
+ );
8319
+ } finally {
8320
+ if (caught !== null) endWith(caught);
8321
+ }
8064
8322
  }
8065
8323
  var WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
8066
8324
  var MAX_FILE_NAME_BYTES = 255;
@@ -8073,6 +8331,62 @@ function savedAs(path) {
8073
8331
  );
8074
8332
  return name;
8075
8333
  }
8334
+ var NETWORK_MESSAGE = "The request did not reach the Naro backend. Check the url and the network.";
8335
+ async function failureOf(response) {
8336
+ const { status } = response;
8337
+ const invalid = {
8338
+ code: "INVALID_RESPONSE",
8339
+ message: `The server did not answer like a Naro backend (HTTP ${String(status)}).`,
8340
+ details: { status }
8341
+ };
8342
+ let text;
8343
+ try {
8344
+ text = await response.text();
8345
+ } catch {
8346
+ return { code: "NETWORK_ERROR", message: NETWORK_MESSAGE };
8347
+ }
8348
+ let body;
8349
+ if (text.trim() !== "") {
8350
+ try {
8351
+ body = JSON.parse(text);
8352
+ } catch {
8353
+ return invalid;
8354
+ }
8355
+ }
8356
+ const error = typeof body === "object" && body !== null && "error" in body ? body.error : void 0;
8357
+ if (typeof error !== "object" || error === null) return invalid;
8358
+ const { code, message, details } = error;
8359
+ if (typeof code !== "string" || code === "" || typeof message !== "string")
8360
+ return invalid;
8361
+ return details === void 0 ? { code, message } : { code, message, details };
8362
+ }
8363
+ async function getObject(url, serviceKey) {
8364
+ let response;
8365
+ try {
8366
+ response = await fetch(url, {
8367
+ headers: { apikey: serviceKey },
8368
+ redirect: "error"
8369
+ });
8370
+ } catch {
8371
+ throw new NaroError("NETWORK_ERROR", NETWORK_MESSAGE);
8372
+ }
8373
+ if (response.ok) return response;
8374
+ return unwrap({ data: null, error: await failureOf(response) });
8375
+ }
8376
+ async function* chunksOf2(response, received) {
8377
+ if (response.body === null) return;
8378
+ try {
8379
+ for await (const chunk2 of Readable.fromWeb(
8380
+ response.body
8381
+ )) {
8382
+ const bytes = chunk2;
8383
+ received.bytes += bytes.byteLength;
8384
+ yield bytes;
8385
+ }
8386
+ } catch {
8387
+ throw new NaroError("NETWORK_ERROR", NETWORK_MESSAGE);
8388
+ }
8389
+ }
8076
8390
  async function download(context, source, local) {
8077
8391
  if (source.path === "" || source.path.endsWith("/"))
8078
8392
  throw new NaroError(
@@ -8088,14 +8402,20 @@ async function download(context, source, local) {
8088
8402
  "VALIDATION_FAILED",
8089
8403
  `Cannot save to ${printable(local)}: its folder does not exist.`
8090
8404
  );
8091
- const { projectId, storage } = await objects(context);
8092
- const blob = unwrap(await storage.from(source.bucket).download(source.path));
8093
- await writeAtomically(blob, destination);
8405
+ const route2 = objectRoute(source.bucket, source.path);
8406
+ if (!route2.ok) throw new NaroError(route2.error.code, route2.error.message);
8407
+ const { projectId, url, serviceKey } = await objects(context);
8408
+ const response = await getObject(
8409
+ `${url}/_naro/v1/storage/object/${route2.encoded}`,
8410
+ serviceKey
8411
+ );
8412
+ const received = { bytes: 0 };
8413
+ await writeAtomically(chunksOf2(response, received), destination);
8094
8414
  const from = `${NARO_SCHEME}${source.bucket}/${source.path}`;
8095
8415
  context.output.result(
8096
- { projectId, from, to: destination, bytes: blob.size },
8416
+ { projectId, from, to: destination, bytes: received.bytes },
8097
8417
  [
8098
- `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(blob.size)})`
8418
+ `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(received.bytes)})`
8099
8419
  ]
8100
8420
  );
8101
8421
  }
@@ -8388,8 +8708,9 @@ Usage:
8388
8708
  naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]
8389
8709
  The buckets, or the folders and objects under a prefix
8390
8710
  naro storage cp <from> <to> [--upsert] [--content-type <t>] [--cache-control <seconds>]
8391
- One file between this machine and naro://<bucket>/<path>, 50 MiB at most,
8711
+ One file between this machine and naro://<bucket>/<path>, up to the object limit (over 50 MiB in parts),
8392
8712
  with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
8713
+ A big upload shows its progress on stderr; a file of 4 GiB or more cannot be uploaded yet.
8393
8714
  A download replaces a local file of the same name
8394
8715
  naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
8395
8716
  Remove objects; --recursive removes what is under a folder (asks first):
package/dist/output.d.ts CHANGED
@@ -21,6 +21,11 @@ export declare class Output {
21
21
  * whose stdout is the deliverable itself (`naro gen types > db.d.ts`).
22
22
  */
23
23
  warn(message: string): void;
24
+ /**
25
+ * Text on stderr exactly as given, with no line break added: a progress
26
+ * line a terminal rewrites in place (`\r…`). Never stdout, in either mode.
27
+ */
28
+ progress(text: string): void;
24
29
  /** Extra diagnostics, only with --debug. */
25
30
  debugLog(message: string): void;
26
31
  /**
package/dist/upload.d.ts CHANGED
@@ -8,4 +8,6 @@ export interface UploadOptions {
8
8
  export declare function uploadArtifact(artifact: DeploymentArtifact, opts: UploadOptions): Promise<{
9
9
  artifactKey: string;
10
10
  previewUrl: string | null;
11
+ /** How many files, how big and how long the upload took. */
12
+ uploadSummary: string;
11
13
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "naro": "./dist/index.js"
@@ -33,12 +33,12 @@
33
33
  "tsx": "4.23.12",
34
34
  "typescript": "7.0.2",
35
35
  "vitest": "3.2.7",
36
- "naro-js": "0.3.0",
36
+ "naro-js": "0.4.0",
37
37
  "@next-deploy/core": "0.1.0",
38
38
  "@next-deploy/data-core": "0.1.0",
39
39
  "@next-deploy/sdk": "0.1.0",
40
- "@next-deploy/config": "0.1.0",
41
- "@next-deploy/build": "0.1.0"
40
+ "@next-deploy/build": "0.1.0",
41
+ "@next-deploy/config": "0.1.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "tsup && pnpm build:types",