naro-cli 0.15.0 → 0.17.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/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-PYRN5XR6.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}
@@ -4554,21 +4560,69 @@ function domainLines(domain) {
4554
4560
  }
4555
4561
  return lines;
4556
4562
  }
4563
+ function claimLines(claim) {
4564
+ return [
4565
+ ` \u23F3 ${claim.hostname} \u2014 another project holds it. Publish this TXT record, then run \`naro domains verify ${claim.hostname}\`:`,
4566
+ ` TXT ${claim.record.name}`,
4567
+ ` host: ${claim.record.host}`,
4568
+ ` value: ${claim.record.value}`
4569
+ ];
4570
+ }
4571
+ async function openClaims(client, projectId) {
4572
+ try {
4573
+ return await client.domains.listClaims(projectId);
4574
+ } catch (error) {
4575
+ if (isNaroError(error) && error.code === "NOT_FOUND") return [];
4576
+ throw error;
4577
+ }
4578
+ }
4557
4579
  async function runDomainsList(context) {
4558
4580
  const client = await context.client();
4559
4581
  const projectId = await context.resolveProjectId();
4560
4582
  const domains = await client.domains.list(projectId);
4583
+ const claims = await openClaims(client, projectId);
4561
4584
  const pending = domains.filter((domain) => !isReady(domain));
4562
4585
  context.output.result(
4563
- { projectId, domains },
4564
- domains.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
4565
- `Domains (${String(domains.length)}):`,
4566
- ...domains.flatMap(domainLines),
4586
+ { projectId, domains, claims },
4587
+ domains.length === 0 && claims.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
4588
+ ...domains.length > 0 ? [
4589
+ `Domains (${String(domains.length)}):`,
4590
+ ...domains.flatMap(domainLines)
4591
+ ] : [],
4592
+ ...claims.length > 0 ? [
4593
+ ...domains.length > 0 ? [""] : [],
4594
+ `Ownership checks (${String(claims.length)}):`,
4595
+ ...claims.flatMap(claimLines)
4596
+ ] : [],
4567
4597
  // list reads our stored copy; only verify goes and looks again.
4568
4598
  ...pending.length > 0 ? ["", "Re-check DNS now: naro domains verify"] : []
4569
4599
  ]
4570
4600
  );
4571
4601
  }
4602
+ async function verifyClaimFor(context, client, projectId, hostname) {
4603
+ const wanted = hostname.toLowerCase().replace(/\.+$/, "");
4604
+ const claim = (await openClaims(client, projectId)).find(
4605
+ (candidate) => candidate.hostname === wanted
4606
+ );
4607
+ if (!claim) return false;
4608
+ const result = await client.domains.verifyClaim(projectId, claim.id);
4609
+ if (!result.verified) {
4610
+ const cause = result.claim.cause;
4611
+ throw new NaroError(
4612
+ cause?.code ?? "DOMAIN_VERIFICATION_REQUIRED",
4613
+ cause ? `${cause.message}${cause.fix ? ` ${cause.fix}` : ""}` : `${wanted} is not verified yet.`,
4614
+ { details: { claim: result.claim } }
4615
+ );
4616
+ }
4617
+ context.output.result({ projectId, verified: true, domain: result.domain }, [
4618
+ `\u2705 Ownership of ${result.domain.hostname} verified \u2014 it now belongs to this project.`,
4619
+ "",
4620
+ ...domainLines(result.domain),
4621
+ "",
4622
+ "Status: Waiting for DNS \u2014 add the CNAME above; naro connects it within about five minutes. Check with `naro domains verify`."
4623
+ ]);
4624
+ return true;
4625
+ }
4572
4626
  async function runDomainsAdd(context) {
4573
4627
  const [hostname] = context.positionalOrThrow(
4574
4628
  1,
@@ -4602,7 +4656,7 @@ async function runDomainsAdd(context) {
4602
4656
  ` refuses the record above, run \`naro domains add ${alternative}\`.`
4603
4657
  ] : [],
4604
4658
  "",
4605
- isReady(domain) ? "The domain is active." : "Status: Pending verification \u2014 add the DNS records above, then run `naro domains verify`."
4659
+ isReady(domain) ? "The domain is active." : "Status: Waiting for DNS \u2014 add the CNAME above; naro connects it within about five minutes. Check with `naro domains verify`."
4606
4660
  ]
4607
4661
  );
4608
4662
  }
@@ -4626,7 +4680,14 @@ async function runDomainsVerify(context, overrides = {}) {
4626
4680
  const progress = (line) => {
4627
4681
  if (!context.flags.json) context.output.log(line);
4628
4682
  };
4629
- let domains = await verifyAll(client, projectId, hostname);
4683
+ let domains;
4684
+ try {
4685
+ domains = await verifyAll(client, projectId, hostname);
4686
+ } catch (error) {
4687
+ if (hostname !== void 0 && isNaroError(error) && error.code === "DOMAIN_NOT_FOUND" && await verifyClaimFor(context, client, projectId, hostname))
4688
+ return;
4689
+ throw error;
4690
+ }
4630
4691
  if (domains.length === 0) {
4631
4692
  context.output.result({ projectId, domains, ready: false }, [
4632
4693
  "No custom domains. Add one with `naro domains add example.com`."
@@ -4685,7 +4746,7 @@ async function runDomainsVerify(context, overrides = {}) {
4685
4746
  const cause = notReady[0]?.cause;
4686
4747
  throw new NaroError(
4687
4748
  cause?.code ?? "DOMAIN_NOT_READY",
4688
- `${names} is not live yet.${cause ? ` ${cause.message}` : ""} Add the records above, then run \`naro domains verify --wait\`.`,
4749
+ `${names} is not live yet.${cause ? ` ${cause.message}` : ""} naro connects it within about five minutes of the CNAME appearing; \`naro domains verify --wait\` waits for it.`,
4689
4750
  { details }
4690
4751
  );
4691
4752
  }
@@ -5337,7 +5398,7 @@ function meterLines(info) {
5337
5398
  }
5338
5399
  if (today.bytesBlocked)
5339
5400
  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).`
5401
+ 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
5402
  );
5342
5403
  if (today.opsBlocked)
5343
5404
  lines.push(
@@ -5362,7 +5423,7 @@ function storageLines(info) {
5362
5423
  const { limits, usage } = info;
5363
5424
  return [
5364
5425
  `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`,
5426
+ `Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per request (larger files go in parts)`,
5366
5427
  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
5428
  ...meterLines(info),
5368
5429
  info.buckets.length === 0 ? "Buckets: none yet (`naro storage bucket create <name>` makes one)" : `Buckets: ${String(info.buckets.length)}`,
@@ -5549,6 +5610,12 @@ function requestTimeout(ms) {
5549
5610
  message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
5550
5611
  };
5551
5612
  }
5613
+ function uploadAborted() {
5614
+ return {
5615
+ code: "ABORTED",
5616
+ message: "The upload was stopped before it finished."
5617
+ };
5618
+ }
5552
5619
  function invalidResponse(status) {
5553
5620
  return {
5554
5621
  code: "INVALID_RESPONSE",
@@ -5662,7 +5729,10 @@ async function call(config, options) {
5662
5729
  }
5663
5730
  }
5664
5731
  const token = options.withToken ? await config.tokens.get() : null;
5665
- const headers = { apikey: config.apiKey };
5732
+ const headers = {
5733
+ ...options.headers,
5734
+ apikey: config.apiKey
5735
+ };
5666
5736
  if (token !== null) headers.Authorization = `Bearer ${token}`;
5667
5737
  const init = {
5668
5738
  method: options.method,
@@ -5714,6 +5784,12 @@ async function send(config, options) {
5714
5784
  credentials: "omit"
5715
5785
  };
5716
5786
  if (options.body !== void 0) init.body = options.body;
5787
+ const { signal } = options;
5788
+ if (signal?.aborted) return { error: uploadAborted(), status: null };
5789
+ const controller = signal ? new AbortController() : null;
5790
+ const stop = () => controller?.abort();
5791
+ if (controller) init.signal = controller.signal;
5792
+ signal?.addEventListener("abort", stop);
5717
5793
  let response;
5718
5794
  try {
5719
5795
  const fetchImpl = config.fetch ?? globalThis.fetch;
@@ -5722,26 +5798,34 @@ async function send(config, options) {
5722
5798
  init
5723
5799
  );
5724
5800
  } catch (error2) {
5725
- return { error: networkError(error2) };
5801
+ return {
5802
+ error: signal?.aborted ? uploadAborted() : networkError(error2),
5803
+ status: null
5804
+ };
5805
+ } finally {
5806
+ signal?.removeEventListener("abort", stop);
5726
5807
  }
5727
- if (response.status >= 200 && response.status < 300)
5728
- return { error: null, response };
5808
+ const { status } = response;
5809
+ if (status >= 200 && status < 300) return { error: null, response };
5729
5810
  let text;
5730
5811
  try {
5731
5812
  text = await response.text();
5732
5813
  } catch (error2) {
5733
- return { error: networkError(error2) };
5814
+ return { error: networkError(error2), status: null };
5734
5815
  }
5735
5816
  let body;
5736
5817
  if (text.trim() !== "") {
5737
5818
  try {
5738
5819
  body = JSON.parse(text);
5739
5820
  } catch {
5740
- return { error: invalidResponse(response.status) };
5821
+ return { error: invalidResponse(status), status };
5741
5822
  }
5742
5823
  }
5743
- const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
5744
- return { error: await settled(config, error, response.headers, token) };
5824
+ const error = errorFromBody(status, body) ?? invalidResponse(status);
5825
+ return {
5826
+ error: await settled(config, error, response.headers, token),
5827
+ status
5828
+ };
5745
5829
  }
5746
5830
  var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
5747
5831
  "RATE_LIMITED",
@@ -7312,12 +7396,187 @@ var TableBuilder = class {
7312
7396
  });
7313
7397
  }
7314
7398
  };
7399
+ function fail(error) {
7400
+ return { data: null, error };
7401
+ }
7402
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7403
+ function dataOf2(status, body, guard) {
7404
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
7405
+ return { data: body.data, error: null };
7406
+ return fail(invalidResponse(status));
7407
+ }
7408
+ async function readJson(response, guard) {
7409
+ let text;
7410
+ try {
7411
+ text = await response.text();
7412
+ } catch (error) {
7413
+ return fail(networkError(error));
7414
+ }
7415
+ let body;
7416
+ try {
7417
+ body = JSON.parse(text);
7418
+ } catch {
7419
+ return fail(invalidResponse(response.status));
7420
+ }
7421
+ return dataOf2(response.status, body, guard);
7422
+ }
7423
+ var ROUTE = "/storage/upload/multipart";
7424
+ var UPLOAD_TOKEN_HEADER = "x-naro-upload-token";
7425
+ var CONCURRENCY = 4;
7426
+ var ATTEMPTS = 4;
7427
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
7428
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
7429
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
7430
+ 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;
7431
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
7432
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
7433
+ function waitAfter(error, attempt22) {
7434
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
7435
+ if (typeof after === "number") return after * 1e3;
7436
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
7437
+ return BACKOFF_MS[attempt22 - 1] ?? 4e3;
7438
+ }
7439
+ var sleepFor = (ms, signal) => new Promise((done) => {
7440
+ if (signal?.aborted) {
7441
+ done();
7442
+ return;
7443
+ }
7444
+ const end = () => {
7445
+ clearTimeout(timer);
7446
+ signal?.removeEventListener("abort", end);
7447
+ done();
7448
+ };
7449
+ const timer = setTimeout(end, ms);
7450
+ signal?.addEventListener("abort", end);
7451
+ });
7452
+ async function uploadInParts(a) {
7453
+ const { signal } = a;
7454
+ const sleep2 = a.sleep ?? sleepFor;
7455
+ const signed = a.uploadToken === void 0 ? {} : { [UPLOAD_TOKEN_HEADER]: a.uploadToken };
7456
+ const withToken = a.uploadToken === void 0;
7457
+ if (signal?.aborted) return fail(uploadAborted());
7458
+ const started = await call(a.http, {
7459
+ method: a.method,
7460
+ path: ROUTE,
7461
+ body: {
7462
+ bucket: a.bucket,
7463
+ path: a.path,
7464
+ size: a.size,
7465
+ contentType: a.contentType,
7466
+ cacheControl: a.cacheControl,
7467
+ // PUT always replaces: the worker refuses `upsert` on it.
7468
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
7469
+ },
7470
+ headers: signed,
7471
+ withToken
7472
+ });
7473
+ if (started.error !== null) return fail(started.error);
7474
+ const plan = dataOf2(
7475
+ started.answer.status,
7476
+ started.answer.body,
7477
+ planFor(a.size)
7478
+ );
7479
+ if (plan.error !== null) return fail(plan.error);
7480
+ const { uploadId, partSize, partCount } = plan.data;
7481
+ const base = `${ROUTE}/${uploadId}`;
7482
+ const progress = (sentBytes2) => {
7483
+ if (signal?.aborted) return;
7484
+ try {
7485
+ const result = a.onProgress?.(sentBytes2, a.size);
7486
+ if (isThenable(result)) result.then(void 0, () => void 0);
7487
+ } catch {
7488
+ }
7489
+ };
7490
+ const callOff = async () => {
7491
+ await call(a.http, {
7492
+ method: "DELETE",
7493
+ path: base,
7494
+ headers: signed,
7495
+ withToken
7496
+ }).catch(() => void 0);
7497
+ };
7498
+ const etags = [];
7499
+ const failure = {
7500
+ error: signal?.aborted ? uploadAborted() : null
7501
+ };
7502
+ let next = 1;
7503
+ let sentBytes = 0;
7504
+ const onAbort = () => {
7505
+ failure.error ??= uploadAborted();
7506
+ };
7507
+ const tryPart = async (n, bytes) => {
7508
+ const sent = await send(a.http, {
7509
+ method: "PUT",
7510
+ path: `${base}/${String(n)}`,
7511
+ headers: { "content-type": "application/octet-stream", ...signed },
7512
+ body: bytes,
7513
+ withToken,
7514
+ signal
7515
+ });
7516
+ if (sent.error !== null) return sent;
7517
+ const read = await readJson(sent.response, partAnswer(n));
7518
+ if (read.error !== null)
7519
+ return { error: read.error, status: sent.response.status };
7520
+ return { etag: read.data.etag };
7521
+ };
7522
+ const sendPart = async (n) => {
7523
+ const from = (n - 1) * partSize;
7524
+ const to = Math.min(from + partSize, a.size);
7525
+ const bytes = a.body instanceof Uint8Array ? a.body.subarray(from, to) : a.body.slice(from, to);
7526
+ for (let attempt22 = 1; ; attempt22 += 1) {
7527
+ const tried = await tryPart(n, bytes);
7528
+ if ("etag" in tried) {
7529
+ etags[n - 1] = tried.etag;
7530
+ sentBytes += to - from;
7531
+ progress(sentBytes);
7532
+ return null;
7533
+ }
7534
+ if (attempt22 === ATTEMPTS || failure.error !== null || !retryable(tried.error, tried.status))
7535
+ return tried.error;
7536
+ await sleep2(waitAfter(tried.error, attempt22), signal);
7537
+ if (failure.error !== null) return tried.error;
7538
+ }
7539
+ };
7540
+ const worker = async () => {
7541
+ while (failure.error === null && next <= partCount) {
7542
+ const n = next;
7543
+ next += 1;
7544
+ const error = await sendPart(n);
7545
+ if (error !== null) failure.error ??= error;
7546
+ }
7547
+ };
7548
+ signal?.addEventListener("abort", onAbort);
7549
+ await Promise.all(
7550
+ Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker)
7551
+ );
7552
+ signal?.removeEventListener("abort", onAbort);
7553
+ if (failure.error !== null) {
7554
+ await callOff();
7555
+ return fail(failure.error);
7556
+ }
7557
+ const completed = await call(a.http, {
7558
+ method: "POST",
7559
+ path: `${base}/complete`,
7560
+ body: {
7561
+ parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
7562
+ },
7563
+ headers: signed,
7564
+ withToken
7565
+ });
7566
+ if (completed.error !== null) {
7567
+ if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
7568
+ await callOff();
7569
+ return fail(completed.error);
7570
+ }
7571
+ return dataOf2(completed.answer.status, completed.answer.body, isUploadResult);
7572
+ }
7573
+ var isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
7315
7574
  var MAX_SINGLE_UPLOAD_BYTES2 = 52428800;
7316
7575
  var STORAGE_MESSAGES = {
7317
7576
  bucket: "The bucket name is not valid.",
7318
- tooLarge: "Uploads over 50 MiB are not supported yet.",
7319
7577
  request: "The storage request is not valid.",
7320
- body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
7578
+ body: "upload takes a Blob, File, ArrayBuffer, typed array or string.",
7579
+ signature: "The signed URL is not valid."
7321
7580
  };
7322
7581
  var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
7323
7582
  "authenticated",
@@ -7340,9 +7599,6 @@ function invalidPath(reason) {
7340
7599
  message: `The object path is not valid: it ${reason}.`
7341
7600
  };
7342
7601
  }
7343
- function objectTooLarge() {
7344
- return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
7345
- }
7346
7602
  function encodePath(path) {
7347
7603
  if (typeof path !== "string" || path === "")
7348
7604
  return { ok: false, error: invalidPath("is empty") };
@@ -7379,6 +7635,16 @@ function encodeBucket(bucket) {
7379
7635
  };
7380
7636
  return { ok: true, encoded: encodeURIComponent(bucket) };
7381
7637
  }
7638
+ function objectRoute(bucket, path) {
7639
+ const encodedBucket = encodeBucket(bucket);
7640
+ if (!encodedBucket.ok) return encodedBucket;
7641
+ const encodedPath = encodePath(path);
7642
+ if (!encodedPath.ok) return encodedPath;
7643
+ return {
7644
+ ok: true,
7645
+ encoded: `${encodedBucket.encoded}/${encodedPath.encoded}`
7646
+ };
7647
+ }
7382
7648
  var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
7383
7649
  function isDetached(buffer) {
7384
7650
  if (!isArrayBuffer(buffer)) return false;
@@ -7459,36 +7725,25 @@ function payloadOf(body, contentType) {
7459
7725
  };
7460
7726
  }
7461
7727
  var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
7462
- function fail(error) {
7463
- return { data: null, error };
7728
+ var SIGNED_UPLOAD_PREFIX = "/_naro/v1/storage/object/upload/sign/";
7729
+ var TOKEN_TEXT = /^[\x21-\x7e]+$/;
7730
+ var TOKEN_PARAMETER = /([?&]token=)[^&#\s"'<>]*/g;
7731
+ function withoutToken(error, token) {
7732
+ const { details } = error;
7733
+ if (!isRecord(details) || typeof details.cause !== "string") return error;
7734
+ let cause = details.cause.replace(TOKEN_PARAMETER, "$1[redacted]");
7735
+ for (const form of [encodeURIComponent(token), token])
7736
+ cause = cause.replaceAll(form, "[redacted]");
7737
+ return { ...error, details: { ...details, cause } };
7464
7738
  }
7465
- var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
7466
7739
  var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
7467
7740
  var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
7468
7741
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
7742
+ var isSignedUpload = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_UPLOAD_PREFIX) && typeof value.token === "string" && value.token !== "" && typeof value.path === "string";
7743
+ var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
7469
7744
  function listOf(guard) {
7470
7745
  return (value) => Array.isArray(value) && value.every(guard);
7471
7746
  }
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
7747
  var StorageFileApi = class {
7493
7748
  #http;
7494
7749
  #auth;
@@ -7500,14 +7755,9 @@ var StorageFileApi = class {
7500
7755
  }
7501
7756
  /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
7502
7757
  #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
- };
7758
+ const target = objectRoute(this.#bucket, path);
7759
+ if (!target.ok) return target;
7760
+ return { ok: true, path: `/storage/object/${route2}${target.encoded}` };
7511
7761
  }
7512
7762
  /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
7513
7763
  #bucketPath(route2) {
@@ -7529,8 +7779,23 @@ var StorageFileApi = class {
7529
7779
  const cache = `max-age=${options.cacheControl ?? "3600"}`;
7530
7780
  const unreadable = headerProblem(options.contentType, cache);
7531
7781
  if (unreadable !== null) return fail(unreadable);
7532
- if (payload.size > MAX_SINGLE_UPLOAD_BYTES2) return fail(objectTooLarge());
7782
+ const { signal } = options;
7783
+ if (signal?.aborted) return fail(uploadAborted());
7533
7784
  await this.#auth.ready();
7785
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES2)
7786
+ return uploadInParts({
7787
+ http: this.#http,
7788
+ method,
7789
+ bucket: this.#bucket,
7790
+ path,
7791
+ body: partsBody(payload.body),
7792
+ size: payload.size,
7793
+ contentType: payload.type,
7794
+ cacheControl: cache,
7795
+ upsert,
7796
+ ...options.onProgress ? { onProgress: options.onProgress } : {},
7797
+ signal
7798
+ });
7534
7799
  const headers = {
7535
7800
  "content-type": payload.type,
7536
7801
  "cache-control": cache
@@ -7541,7 +7806,9 @@ var StorageFileApi = class {
7541
7806
  path: target.path,
7542
7807
  headers,
7543
7808
  body: payload.body,
7544
- withToken: true
7809
+ withToken: true,
7810
+ // The fetch stops with the body: the worker undoes its claim at once.
7811
+ signal
7545
7812
  });
7546
7813
  if (sent.error !== null) return fail(sent.error);
7547
7814
  return readJson(sent.response, isUploadResult);
@@ -7649,6 +7916,101 @@ var StorageFileApi = class {
7649
7916
  error: null
7650
7917
  };
7651
7918
  }
7919
+ /**
7920
+ * A token that lets whoever holds it upload `path` for two hours (Storage
7921
+ * 3-1e-2), as supabase-js's: made where the key is — a server with the
7922
+ * service key for any path, anon or a signed-in user only where the
7923
+ * policies let them upload. With `upsert: true` it may replace a file
7924
+ * that is there.
7925
+ * The token goes to `uploadToSignedUrl`; `signedUrl` is where a client
7926
+ * without naro-js PUTs the bytes.
7927
+ */
7928
+ async createSignedUploadUrl(path, options = {}) {
7929
+ const target = this.#objectPath("upload/sign/", path);
7930
+ if (!target.ok) return fail(target.error);
7931
+ const upsert = (options ?? {}).upsert === true;
7932
+ const signed = await this.#json(
7933
+ {
7934
+ method: "POST",
7935
+ path: target.path,
7936
+ body: upsert ? { upsert: true } : {}
7937
+ },
7938
+ isSignedUpload
7939
+ );
7940
+ if (signed.error !== null) return signed;
7941
+ return {
7942
+ data: {
7943
+ signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}`,
7944
+ token: signed.data.token,
7945
+ path: signed.data.path
7946
+ },
7947
+ error: null
7948
+ };
7949
+ }
7950
+ /**
7951
+ * Uploads `body` to `path` with a token from `createSignedUploadUrl`
7952
+ * (Storage 3-1e-2). No session is sent, or needed: the token is the
7953
+ * permission, so a page's anon-key client uploads where only a server
7954
+ * could sign. It works until the token expires, two hours from when it
7955
+ * was made; one made without `upsert` uploads one new file
7956
+ * (`OBJECT_EXISTS` for a name that is there). A body over 50 MiB goes in
7957
+ * parts with the token on every step, and once started may finish after
7958
+ * the two hours. `contentType`, `cacheControl`, `onProgress` and `signal`
7959
+ * are `upload`'s.
7960
+ */
7961
+ async uploadToSignedUrl(path, token, body, options = {}) {
7962
+ const given = options ?? {};
7963
+ const target = this.#objectPath("upload/sign/", path);
7964
+ if (!target.ok) return fail(target.error);
7965
+ if (typeof token !== "string" || !TOKEN_TEXT.test(token))
7966
+ return fail({
7967
+ code: "INVALID_SIGNATURE",
7968
+ message: STORAGE_MESSAGES.signature
7969
+ });
7970
+ const payload = payloadOf(body, given.contentType);
7971
+ if (!payload.ok) return fail(payload.error);
7972
+ const cache = `max-age=${given.cacheControl ?? "3600"}`;
7973
+ const unreadable = headerProblem(given.contentType, cache);
7974
+ if (unreadable !== null) return fail(unreadable);
7975
+ const { signal } = given;
7976
+ if (signal?.aborted) return fail(uploadAborted());
7977
+ const uploaded = payload.size > MAX_SINGLE_UPLOAD_BYTES2 ? await uploadInParts({
7978
+ http: this.#http,
7979
+ method: "POST",
7980
+ bucket: this.#bucket,
7981
+ path,
7982
+ body: partsBody(payload.body),
7983
+ size: payload.size,
7984
+ contentType: payload.type,
7985
+ cacheControl: cache,
7986
+ upsert: false,
7987
+ uploadToken: token,
7988
+ ...given.onProgress ? { onProgress: given.onProgress } : {},
7989
+ signal
7990
+ }) : await this.#putSigned(target.path, token, payload, cache, signal);
7991
+ if (uploaded.error !== null) return fail(uploaded.error);
7992
+ return {
7993
+ data: { path: uploaded.data.path, fullPath: uploaded.data.fullPath },
7994
+ error: null
7995
+ };
7996
+ }
7997
+ /**
7998
+ * The one-request signed upload: the token in the query, no session — it
7999
+ * is the permission. A failure's cause never carries the token.
8000
+ */
8001
+ async #putSigned(path, token, payload, cache, signal) {
8002
+ const sent = await send(this.#http, {
8003
+ method: "PUT",
8004
+ path: `${path}?token=${encodeURIComponent(token)}`,
8005
+ headers: { "content-type": payload.type, "cache-control": cache },
8006
+ body: payload.body,
8007
+ withToken: false,
8008
+ signal
8009
+ });
8010
+ if (sent.error !== null) return fail(withoutToken(sent.error, token));
8011
+ const read = await readJson(sent.response, isUploadResult);
8012
+ return read.error === null ? read : fail(withoutToken(read.error, token));
8013
+ }
7652
8014
  /**
7653
8015
  * A public bucket's URL for the object. No request is made, so whether
7654
8016
  * the bucket is public is not checked (as in supabase-js); a path no URL
@@ -7787,6 +8149,7 @@ var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<pa
7787
8149
  var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
7788
8150
  var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]";
7789
8151
  var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
8152
+ var FOUR_GIB = 4294967296;
7790
8153
  var PAGE = 1e3;
7791
8154
  var MAX_OFFSET = 1e5;
7792
8155
  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 +8252,8 @@ async function objects(context) {
7889
8252
  { cause: error }
7890
8253
  );
7891
8254
  }
7892
- return { projectId, storage: naro.storage };
8255
+ const url = new URL(backend.url.trim().replace(/\/+$/, "")).origin;
8256
+ return { projectId, storage: naro.storage, url, serviceKey };
7893
8257
  }
7894
8258
  function isFolder(entry) {
7895
8259
  return entry.id === null;
@@ -7982,33 +8346,56 @@ async function storageLs(context) {
7982
8346
  );
7983
8347
  }
7984
8348
  var INTERRUPTS = ["SIGINT", "SIGTERM", "SIGHUP"];
7985
- function removeOnInterrupt(partial) {
7986
- const stop = () => {
8349
+ function endWith(signal) {
8350
+ try {
8351
+ process.kill(process.pid, signal);
8352
+ } catch {
8353
+ process.exit(1);
8354
+ }
8355
+ }
8356
+ function onInterrupt(onSignal) {
8357
+ for (const signal of INTERRUPTS) process.on(signal, onSignal);
8358
+ return () => {
7987
8359
  for (const signal of INTERRUPTS) process.off(signal, onSignal);
7988
8360
  };
7989
- function onSignal(signal) {
8361
+ }
8362
+ function removeOnInterrupt(partial) {
8363
+ const stop = onInterrupt((signal) => {
7990
8364
  stop();
7991
8365
  try {
7992
8366
  rmSync2(partial, { force: true });
7993
8367
  } catch {
7994
8368
  }
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);
8369
+ endWith(signal);
8370
+ });
8002
8371
  return stop;
8003
8372
  }
8004
- async function writeAtomically(blob, destination) {
8373
+ function stopOnInterrupt(onFirst) {
8374
+ const controller = new AbortController();
8375
+ let caught = null;
8376
+ const stop = onInterrupt((signal) => {
8377
+ if (caught !== null) {
8378
+ stop();
8379
+ endWith(signal);
8380
+ return;
8381
+ }
8382
+ caught = signal;
8383
+ onFirst();
8384
+ controller.abort();
8385
+ });
8386
+ return {
8387
+ signal: controller.signal,
8388
+ settle: () => {
8389
+ stop();
8390
+ return caught;
8391
+ }
8392
+ };
8393
+ }
8394
+ async function writeAtomically(bytes, destination) {
8005
8395
  const partial = join6(dirname3(destination), `.${randomUUID()}.naro-partial`);
8006
8396
  const stopListening = removeOnInterrupt(partial);
8007
8397
  try {
8008
- await pipeline2(
8009
- Readable.fromWeb(blob.stream()),
8010
- createWriteStream2(partial, { flags: "wx" })
8011
- );
8398
+ await pipeline2(bytes, createWriteStream2(partial, { flags: "wx" }));
8012
8399
  await rename(partial, destination);
8013
8400
  } catch (error) {
8014
8401
  await rm(partial, { force: true }).catch(() => void 0);
@@ -8017,6 +8404,34 @@ async function writeAtomically(blob, destination) {
8017
8404
  stopListening();
8018
8405
  }
8019
8406
  }
8407
+ function progressReporter(output, total) {
8408
+ const tty = process.stderr.isTTY === true;
8409
+ let lastTenth = -1;
8410
+ let lastLength = 0;
8411
+ let open = false;
8412
+ return {
8413
+ report(sent) {
8414
+ const percent2 = Math.floor(sent / total * 100);
8415
+ const line = `\u2191 ${binarySize(sent)} / ${binarySize(total)} (${String(percent2)}%)`;
8416
+ if (tty) {
8417
+ open = sent !== total;
8418
+ output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
8419
+ lastLength = line.length;
8420
+ return;
8421
+ }
8422
+ const tenth = Math.floor(percent2 / 10);
8423
+ if (tenth === lastTenth && sent !== total) return;
8424
+ lastTenth = tenth;
8425
+ output.progress(`${line}
8426
+ `);
8427
+ },
8428
+ close() {
8429
+ if (!open) return;
8430
+ open = false;
8431
+ output.progress("\n");
8432
+ }
8433
+ };
8434
+ }
8020
8435
  async function upload(context, local, target) {
8021
8436
  const file = resolve5(context.cwd, local);
8022
8437
  const info = await stat(file).catch(() => null);
@@ -8035,32 +8450,49 @@ async function upload(context, local, target) {
8035
8450
  "VALIDATION_FAILED",
8036
8451
  `${printable(local)} is not a regular file.`
8037
8452
  );
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
8453
  const { cacheControl } = context.flags;
8044
8454
  if (cacheControl !== void 0 && !isMaxAge(cacheControl))
8045
8455
  throw new NaroError("VALIDATION_FAILED", CACHE_CONTROL_RULE);
8046
8456
  const path = target.path === "" || target.path.endsWith("/") ? `${target.path}${basename2(file)}` : target.path;
8047
- const { projectId, storage } = await objects(context);
8048
8457
  const body = await openAsBlob(file);
8458
+ if (body.size !== info.size)
8459
+ throw new NaroError(
8460
+ "VALIDATION_FAILED",
8461
+ 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.`
8462
+ );
8463
+ const { projectId, storage } = await objects(context);
8049
8464
  const contentType = context.flags.contentType ?? typeFor(file);
8050
- const result = unwrap(
8051
- await storage.from(target.bucket).upload(path, body, {
8465
+ const progress = body.size > MAX_SINGLE_UPLOAD_BYTES && !context.output.json ? progressReporter(context.output, body.size) : void 0;
8466
+ const interrupt = stopOnInterrupt(() => {
8467
+ progress?.close();
8468
+ context.output.progress("Stopping the upload\u2026\n");
8469
+ });
8470
+ let answer;
8471
+ let caught;
8472
+ try {
8473
+ answer = await storage.from(target.bucket).upload(path, body, {
8052
8474
  ...contentType === void 0 ? {} : { contentType },
8053
8475
  ...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
- );
8476
+ upsert: context.flags.upsert === true,
8477
+ ...progress === void 0 ? {} : { onProgress: progress.report },
8478
+ signal: interrupt.signal
8479
+ });
8480
+ } finally {
8481
+ progress?.close();
8482
+ caught = interrupt.settle();
8483
+ }
8484
+ try {
8485
+ const result = unwrap(answer);
8486
+ const to = `${NARO_SCHEME}${target.bucket}/${path}`;
8487
+ context.output.result(
8488
+ { projectId, from: local, to, bytes: body.size, id: result.id },
8489
+ [
8490
+ `\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
8491
+ ]
8492
+ );
8493
+ } finally {
8494
+ if (caught !== null) endWith(caught);
8495
+ }
8064
8496
  }
8065
8497
  var WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
8066
8498
  var MAX_FILE_NAME_BYTES = 255;
@@ -8073,6 +8505,62 @@ function savedAs(path) {
8073
8505
  );
8074
8506
  return name;
8075
8507
  }
8508
+ var NETWORK_MESSAGE = "The request did not reach the Naro backend. Check the url and the network.";
8509
+ async function failureOf(response) {
8510
+ const { status } = response;
8511
+ const invalid = {
8512
+ code: "INVALID_RESPONSE",
8513
+ message: `The server did not answer like a Naro backend (HTTP ${String(status)}).`,
8514
+ details: { status }
8515
+ };
8516
+ let text;
8517
+ try {
8518
+ text = await response.text();
8519
+ } catch {
8520
+ return { code: "NETWORK_ERROR", message: NETWORK_MESSAGE };
8521
+ }
8522
+ let body;
8523
+ if (text.trim() !== "") {
8524
+ try {
8525
+ body = JSON.parse(text);
8526
+ } catch {
8527
+ return invalid;
8528
+ }
8529
+ }
8530
+ const error = typeof body === "object" && body !== null && "error" in body ? body.error : void 0;
8531
+ if (typeof error !== "object" || error === null) return invalid;
8532
+ const { code, message, details } = error;
8533
+ if (typeof code !== "string" || code === "" || typeof message !== "string")
8534
+ return invalid;
8535
+ return details === void 0 ? { code, message } : { code, message, details };
8536
+ }
8537
+ async function getObject(url, serviceKey) {
8538
+ let response;
8539
+ try {
8540
+ response = await fetch(url, {
8541
+ headers: { apikey: serviceKey },
8542
+ redirect: "error"
8543
+ });
8544
+ } catch {
8545
+ throw new NaroError("NETWORK_ERROR", NETWORK_MESSAGE);
8546
+ }
8547
+ if (response.ok) return response;
8548
+ return unwrap({ data: null, error: await failureOf(response) });
8549
+ }
8550
+ async function* chunksOf2(response, received) {
8551
+ if (response.body === null) return;
8552
+ try {
8553
+ for await (const chunk2 of Readable.fromWeb(
8554
+ response.body
8555
+ )) {
8556
+ const bytes = chunk2;
8557
+ received.bytes += bytes.byteLength;
8558
+ yield bytes;
8559
+ }
8560
+ } catch {
8561
+ throw new NaroError("NETWORK_ERROR", NETWORK_MESSAGE);
8562
+ }
8563
+ }
8076
8564
  async function download(context, source, local) {
8077
8565
  if (source.path === "" || source.path.endsWith("/"))
8078
8566
  throw new NaroError(
@@ -8088,14 +8576,20 @@ async function download(context, source, local) {
8088
8576
  "VALIDATION_FAILED",
8089
8577
  `Cannot save to ${printable(local)}: its folder does not exist.`
8090
8578
  );
8091
- const { projectId, storage } = await objects(context);
8092
- const blob = unwrap(await storage.from(source.bucket).download(source.path));
8093
- await writeAtomically(blob, destination);
8579
+ const route2 = objectRoute(source.bucket, source.path);
8580
+ if (!route2.ok) throw new NaroError(route2.error.code, route2.error.message);
8581
+ const { projectId, url, serviceKey } = await objects(context);
8582
+ const response = await getObject(
8583
+ `${url}/_naro/v1/storage/object/${route2.encoded}`,
8584
+ serviceKey
8585
+ );
8586
+ const received = { bytes: 0 };
8587
+ await writeAtomically(chunksOf2(response, received), destination);
8094
8588
  const from = `${NARO_SCHEME}${source.bucket}/${source.path}`;
8095
8589
  context.output.result(
8096
- { projectId, from, to: destination, bytes: blob.size },
8590
+ { projectId, from, to: destination, bytes: received.bytes },
8097
8591
  [
8098
- `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(blob.size)})`
8592
+ `\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(received.bytes)})`
8099
8593
  ]
8100
8594
  );
8101
8595
  }
@@ -8388,8 +8882,9 @@ Usage:
8388
8882
  naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]
8389
8883
  The buckets, or the folders and objects under a prefix
8390
8884
  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,
8885
+ One file between this machine and naro://<bucket>/<path>, up to the object limit (over 50 MiB in parts),
8392
8886
  with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
8887
+ A big upload shows its progress on stderr; a file of 4 GiB or more cannot be uploaded yet.
8393
8888
  A download replaces a local file of the same name
8394
8889
  naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
8395
8890
  Remove objects; --recursive removes what is under a folder (asks first):