naro-cli 0.19.0 → 0.20.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
@@ -18,14 +18,17 @@ import {
18
18
  SPEED_INSIGHTS_METRICS,
19
19
  SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
20
20
  STORAGE_OBJECTS_TABLE,
21
+ STORAGE_SUBQUERIES_SQL,
21
22
  buildResetPlan,
22
23
  cellText,
24
+ checkObjectName,
23
25
  clearSession,
24
26
  commentText,
25
27
  compareMigrations,
26
28
  controlPlaneUrl,
27
29
  countLintedTables,
28
30
  createAuthClient,
31
+ dataError,
29
32
  deployDirectory,
30
33
  detectGitInfo,
31
34
  ensureGitignoreHasNaro,
@@ -50,7 +53,7 @@ import {
50
53
  nextMigrationFileName,
51
54
  openBrowser,
52
55
  parseCacheControl,
53
- parseDotenv,
56
+ parseDotenvDetailed,
54
57
  parseGitHubRemote,
55
58
  parsePolicyNode,
56
59
  parsePolicyStatement,
@@ -60,6 +63,7 @@ import {
60
63
  policiesForTypes,
61
64
  policiesLostByReset,
62
65
  projectPath,
66
+ promoteTargetUnsupported,
63
67
  promptForMissingEnv,
64
68
  pullEnv,
65
69
  quoteIdent,
@@ -71,6 +75,7 @@ import {
71
75
  resetFileRefusedMessage,
72
76
  resetPoliciesLostMessage,
73
77
  resetPoliciesWithTablesMessage,
78
+ resetStorageOutdatedMessage,
74
79
  resolveAuth,
75
80
  resolveMigrationsDir,
76
81
  saveSession,
@@ -80,6 +85,7 @@ import {
80
85
  snapshotFromRows,
81
86
  splitPolicyStatements,
82
87
  splitStatements,
88
+ storageRefusalOfFiles,
83
89
  storedPolicySql,
84
90
  summarizeStatement,
85
91
  summarizeTeamUsage,
@@ -91,7 +97,7 @@ import {
91
97
  usageMonthOf,
92
98
  userForAccessToken,
93
99
  writeProjectLink
94
- } from "./chunk-DEEFHVDZ.js";
100
+ } from "./chunk-SAA66XLD.js";
95
101
  import "./chunk-K3NQKI34.js";
96
102
 
97
103
  // src/args.ts
@@ -190,7 +196,8 @@ var STRING_FLAGS = {
190
196
  "--search": "search",
191
197
  "--copy-env": "copyEnv",
192
198
  "--database": "database",
193
- "--protection": "protection"
199
+ "--protection": "protection",
200
+ "--to": "to"
194
201
  };
195
202
  var SECRET_FLAGS = {
196
203
  "--api-key": "--api-key-stdin",
@@ -2644,12 +2651,45 @@ async function hasBackend(client, projectId) {
2644
2651
  throw error;
2645
2652
  }
2646
2653
  }
2647
- async function policyPlan(client, projectId, schema, dir, files) {
2648
- if (!schema.policyTable) {
2649
- if (await hasBackend(client, projectId))
2650
- throw new NaroError("BACKEND_OUTDATED", RESET_BACKEND_OUTDATED);
2651
- return { clearPolicies: false, lost: [], withTables: [] };
2654
+ async function refuseStorageOutdated(client, projectId, storageTable, files, texts) {
2655
+ let refused = storageRefusalOfFiles(texts, {
2656
+ storage: storageTable,
2657
+ storageSubqueries: false
2658
+ });
2659
+ if (refused?.needs === "subqueries" && storageTable) {
2660
+ const answer = await client.db.query(projectId, {
2661
+ sql: STORAGE_SUBQUERIES_SQL
2662
+ });
2663
+ if ((answer.statements[0]?.rows.length ?? 0) > 0)
2664
+ refused = storageRefusalOfFiles(texts, {
2665
+ storage: true,
2666
+ storageSubqueries: true
2667
+ });
2652
2668
  }
2669
+ if (refused)
2670
+ throw new NaroError(
2671
+ "BACKEND_OUTDATED",
2672
+ resetStorageOutdatedMessage(files[refused.file] ?? "", refused),
2673
+ {
2674
+ details: {
2675
+ migration: files[refused.file] ?? "",
2676
+ statement: refused.statement
2677
+ }
2678
+ }
2679
+ );
2680
+ }
2681
+ async function policyPlan(client, projectId, schema, files, texts) {
2682
+ if (!schema.policyTable && await hasBackend(client, projectId))
2683
+ throw new NaroError("BACKEND_OUTDATED", RESET_BACKEND_OUTDATED);
2684
+ await refuseStorageOutdated(
2685
+ client,
2686
+ projectId,
2687
+ schema.storageTable,
2688
+ files,
2689
+ texts
2690
+ );
2691
+ if (!schema.policyTable)
2692
+ return { clearPolicies: false, lost: [], withTables: [] };
2653
2693
  const answer = await client.db.query(projectId, {
2654
2694
  sql: NAMED_POLICY_SQL.list
2655
2695
  });
@@ -2678,7 +2718,7 @@ async function policyPlan(client, projectId, schema, dir, files) {
2678
2718
  ...policiesLostByReset({
2679
2719
  current,
2680
2720
  tables: schema.tables,
2681
- files: files.map((name) => readFileSync3(join3(dir, name), "utf8")),
2721
+ files: texts,
2682
2722
  // Storage's table stays; its policies are emptied with the rest (PR
2683
2723
  // #85 리뷰). Realtime's messages need no flag: a virtual target, which
2684
2724
  // reset-plan counts as a table always there (Realtime 3-2a).
@@ -2716,6 +2756,7 @@ async function runDbReset(context) {
2716
2756
  ${problemLines.join("\n")}`,
2717
2757
  { details: { problems } }
2718
2758
  );
2759
+ const texts = files.map((name) => readFileSync3(join3(dir, name), "utf8"));
2719
2760
  const client = await context.client();
2720
2761
  const projectId = await context.resolveProjectId();
2721
2762
  const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
@@ -2731,8 +2772,8 @@ ${problemLines.join("\n")}`,
2731
2772
  client,
2732
2773
  projectId,
2733
2774
  { policyTable: policyTable2, storageTable, tables },
2734
- dir,
2735
- files
2775
+ files,
2776
+ texts
2736
2777
  );
2737
2778
  const policiesNotInFiles = policies.lost.map((policy) => ({
2738
2779
  name: `${policy.table}.${policy.name}`,
@@ -4074,13 +4115,16 @@ async function runGitConnect(context, overrides = {}) {
4074
4115
  if (cannotAsk(context)) {
4075
4116
  throw new NaroError(
4076
4117
  "REPOSITORY_NOT_ACCESSIBLE",
4077
- `Naro cannot reach ${fullName} yet. Install the Naro GitHub App at ${first.installUrl}, then run: naro git connect`,
4118
+ `Naro cannot reach ${fullName} yet. Open ${first.installUrl} to give Naro access (if the repository is not among the ones the Naro GitHub App was granted on GitHub, add it there first), then run: naro git connect`,
4078
4119
  { details: { projectId, installUrl: first.installUrl } }
4079
4120
  );
4080
4121
  }
4081
4122
  output.log(`
4082
4123
  \u25B8 Naro needs access to ${fullName}.`);
4083
4124
  output.log(` Opening ${first.installUrl}`);
4125
+ output.log(
4126
+ " If the repository is not among the ones the Naro GitHub App was granted on GitHub, add it there first."
4127
+ );
4084
4128
  deps.openBrowser(first.installUrl);
4085
4129
  output.log(" Waiting for the installation to finish...\n");
4086
4130
  await waitForInstall(context, projectId, fullName, deps);
@@ -4848,6 +4892,7 @@ var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4848
4892
  var KEY_MAX_LENGTH = 256;
4849
4893
  var IMPORT_BATCH_SIZE = 100;
4850
4894
  var VALUE_MAX_BYTES = 5 * 1024;
4895
+ var STATIC_ASSETS_BINDING = "ASSETS";
4851
4896
  var TARGET_USAGE = "[production|preview|all|<environment>]";
4852
4897
  function validKey(key) {
4853
4898
  return KEY_RE.test(key) && key.length <= KEY_MAX_LENGTH;
@@ -4960,7 +5005,14 @@ async function runEnvImport(context) {
4960
5005
  } catch {
4961
5006
  throw new NaroError("VALIDATION_FAILED", `Cannot read ${file}.`);
4962
5007
  }
4963
- const vars = Object.entries(parseDotenv(text));
5008
+ const parsed = parseDotenvDetailed(text);
5009
+ const [unclosed] = parsed.unclosedQuotes;
5010
+ if (unclosed !== void 0)
5011
+ throw new NaroError(
5012
+ "VALIDATION_FAILED",
5013
+ `Unclosed quote in ${file} at line ${String(unclosed)}.`
5014
+ );
5015
+ const vars = Object.entries(parsed.vars);
4964
5016
  if (vars.length === 0)
4965
5017
  throw new NaroError("VALIDATION_FAILED", `No variables found in ${file}.`);
4966
5018
  const invalid = vars.map(([key]) => key).filter((key) => !validKey(key));
@@ -4969,6 +5021,11 @@ async function runEnvImport(context) {
4969
5021
  "VALIDATION_FAILED",
4970
5022
  `Invalid keys in ${file}: ${invalid.join(", ")}.`
4971
5023
  );
5024
+ if (vars.some(([key]) => key === STATIC_ASSETS_BINDING))
5025
+ throw new NaroError(
5026
+ "VALIDATION_FAILED",
5027
+ `${STATIC_ASSETS_BINDING} is reserved for the project's static files; pick another name.`
5028
+ );
4972
5029
  const oversized = vars.filter(([, value]) => Buffer.byteLength(value, "utf8") > VALUE_MAX_BYTES).map(([key]) => key);
4973
5030
  if (oversized.length > 0)
4974
5031
  throw new NaroError(
@@ -4978,7 +5035,9 @@ async function runEnvImport(context) {
4978
5035
  const projectId = await context.resolveProjectId();
4979
5036
  context.output.log(`Variables in ${file}:`);
4980
5037
  for (const [key, value] of vars)
4981
- context.output.log(` ${key.padEnd(32)} ${maskEnvValue(key, value)}`);
5038
+ context.output.log(
5039
+ ` ${key.padEnd(32)} ${value === "" ? "(empty)" : maskEnvValue(key, value)}`
5040
+ );
4982
5041
  await confirmOrAbort(
4983
5042
  context,
4984
5043
  `Upload ${variables(vars.length)} to ${target}?`
@@ -4990,6 +5049,15 @@ async function runEnvImport(context) {
4990
5049
  target,
4991
5050
  isSecret: isSecretKey(key)
4992
5051
  }));
5052
+ if (entries.length > IMPORT_BATCH_SIZE) {
5053
+ const taken = new Set(await client.env.takenBindingNames(projectId));
5054
+ const conflict = entries.find((entry) => taken.has(entry.key));
5055
+ if (conflict)
5056
+ throw new NaroError(
5057
+ "VALIDATION_FAILED",
5058
+ `${conflict.key} is already used by a storage binding.`
5059
+ );
5060
+ }
4993
5061
  for (let start = 0; start < entries.length; start += IMPORT_BATCH_SIZE)
4994
5062
  await client.env.set(
4995
5063
  projectId,
@@ -5017,17 +5085,14 @@ async function runEnvPull(context) {
5017
5085
  const rows = await client.env.list(projectId, { reveal: true });
5018
5086
  const vars = pullEnv(rows, surface);
5019
5087
  const count2 = Object.keys(vars).length;
5088
+ const text = `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
5089
+ ${serializeDotenv(vars)}`;
5020
5090
  const path = resolve4(context.cwd, file);
5021
5091
  if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
5022
5092
  const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
5023
5093
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
5024
5094
  }
5025
- writeFileSync4(
5026
- path,
5027
- `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
5028
- ${serializeDotenv(vars)}`,
5029
- { mode: 384 }
5030
- );
5095
+ writeFileSync4(path, text, { mode: 384 });
5031
5096
  chmodSync(path, 384);
5032
5097
  context.output.result({ projectId, file, environment: surface, count: count2 }, [
5033
5098
  `\u2705 Pulled ${String(count2)} variable(s) into ${file}`
@@ -5460,17 +5525,27 @@ async function runProjectInspect(context) {
5460
5525
  }
5461
5526
 
5462
5527
  // src/commands/promote.ts
5463
- async function promoteAndWait(context, client, project, sourceDeploymentId, action) {
5528
+ var PROMOTE_USAGE = "Usage: naro promote <deployment-id> [--to <environment>]";
5529
+ var ROLLBACK_USAGE = "Usage: naro rollback [deployment-id]";
5530
+ var REBUILD_TIMEOUT_MS = 15 * 6e4;
5531
+ var REUPLOAD_TIMEOUT_MS = 5 * 6e4;
5532
+ async function promoteAndWait(context, client, project, sourceDeploymentId, action, target) {
5464
5533
  const promoted = await client.deployments.promote(
5465
5534
  project.id,
5466
- sourceDeploymentId
5535
+ sourceDeploymentId,
5536
+ target !== void 0 ? { target } : void 0
5467
5537
  );
5538
+ const rebuild = promoted.mode === "rebuild";
5468
5539
  context.output.log(
5469
- `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}`
5540
+ `\u25B8 ${action === "promote" ? "promoting" : "rolling back to"} ${sourceDeploymentId}${target !== void 0 ? ` to ${target} (${promoted.mode})` : ""}`
5541
+ );
5542
+ const detail = await client.deployments.wait(
5543
+ promoted.id,
5544
+ rebuild ? {
5545
+ timeoutMs: REBUILD_TIMEOUT_MS,
5546
+ onLog: (log) => context.output.log(formatBuildLog(log))
5547
+ } : { timeoutMs: REUPLOAD_TIMEOUT_MS }
5470
5548
  );
5471
- const detail = await client.deployments.wait(promoted.id, {
5472
- timeoutMs: 5 * 6e4
5473
- });
5474
5549
  if (detail.deployment.status !== "READY") {
5475
5550
  throw new NaroError(
5476
5551
  detail.deployment.errorCode ?? "DEPLOY_FAILED",
@@ -5478,42 +5553,104 @@ async function promoteAndWait(context, client, project, sourceDeploymentId, acti
5478
5553
  { details: { deploymentId: promoted.id } }
5479
5554
  );
5480
5555
  }
5556
+ const serving = target === void 0 || target === "production" ? "Production" : target;
5481
5557
  context.output.result(
5482
5558
  {
5483
5559
  deploymentId: promoted.id,
5484
5560
  sourceDeploymentId,
5485
5561
  projectId: project.id,
5562
+ // Both say the mode the control plane answered (MCP's rollback does
5563
+ // too). Only a promotion has a target: a rollback is production's.
5564
+ ...action === "promote" ? { target: target ?? null } : {},
5565
+ mode: promoted.mode,
5486
5566
  status: detail.deployment.status,
5487
5567
  url: detail.deployment.url
5488
5568
  },
5489
5569
  [
5490
- `\u2705 Production now serves ${sourceDeploymentId}`,
5570
+ `\u2705 ${serving} now serves ${rebuild ? "a rebuild of " : ""}${sourceDeploymentId}`,
5491
5571
  "",
5492
5572
  detail.deployment.url ?? ""
5493
5573
  ]
5494
5574
  );
5495
5575
  }
5576
+ function promoteTarget(flags) {
5577
+ if (flags.to === void 0) return void 0;
5578
+ const target = flags.to.trim();
5579
+ if (target === "" || target === "preview")
5580
+ throw new NaroError(
5581
+ "VALIDATION_FAILED",
5582
+ "--to must be production or an environment name."
5583
+ );
5584
+ return target;
5585
+ }
5586
+ function refuseWhatIsNotRead(context, usage, envMessage) {
5587
+ if (context.positionals.length > 1)
5588
+ throw new NaroError("VALIDATION_FAILED", usage);
5589
+ if (context.flags.env !== void 0)
5590
+ throw new NaroError("VALIDATION_FAILED", envMessage);
5591
+ }
5592
+ async function previewPromotion(context, client, projectId, sourceDeploymentId, target) {
5593
+ let check;
5594
+ try {
5595
+ check = await client.deployments.promoteCheck(
5596
+ projectId,
5597
+ sourceDeploymentId,
5598
+ target
5599
+ );
5600
+ } catch (error) {
5601
+ if (!isNaroError(error) || error.status !== 405) throw error;
5602
+ if (target !== void 0 && target !== "production")
5603
+ throw promoteTargetUnsupported(error);
5604
+ return;
5605
+ }
5606
+ context.output.log(
5607
+ check.mode === "rebuild" ? `\u25B8 rebuild: builds the same commit again with ${target ?? "production"}'s variables` : "\u25B8 reupload: publishes the stored build"
5608
+ );
5609
+ for (const warning of check.warnings)
5610
+ for (const line of warningLines(warning)) context.output.log(line);
5611
+ }
5496
5612
  async function runPromote(context) {
5497
- const [deploymentId] = context.positionalOrThrow(
5498
- 1,
5499
- "Usage: naro promote <deployment-id>"
5613
+ const [deploymentId] = context.positionalOrThrow(1, PROMOTE_USAGE);
5614
+ refuseWhatIsNotRead(
5615
+ context,
5616
+ PROMOTE_USAGE,
5617
+ "--env is not an option of naro promote: use --to <environment>."
5500
5618
  );
5619
+ const target = promoteTarget(context.flags);
5501
5620
  const client = await context.client();
5502
5621
  const projectId = await context.resolveProjectId();
5503
5622
  const project = await client.projects.get(projectId);
5623
+ await previewPromotion(
5624
+ context,
5625
+ client,
5626
+ projectId,
5627
+ deploymentId,
5628
+ target
5629
+ );
5504
5630
  await confirmOrAbort(
5505
5631
  context,
5506
- `Promote ${String(deploymentId)} to production (${project.subdomain})?`
5632
+ target === void 0 || target === "production" ? `Promote ${String(deploymentId)} to production (${project.subdomain})?` : `Promote ${String(deploymentId)} to ${target} (${project.subdomain}--${target})?`
5507
5633
  );
5508
5634
  await promoteAndWait(
5509
5635
  context,
5510
5636
  client,
5511
5637
  project,
5512
5638
  deploymentId,
5513
- "promote"
5639
+ "promote",
5640
+ target
5514
5641
  );
5515
5642
  }
5516
5643
  async function runRollback(context) {
5644
+ if (context.flags.to !== void 0)
5645
+ throw new NaroError(
5646
+ "VALIDATION_FAILED",
5647
+ "--to is for naro promote: naro rollback always rolls production back."
5648
+ );
5649
+ refuseWhatIsNotRead(
5650
+ context,
5651
+ ROLLBACK_USAGE,
5652
+ "--env is not an option of naro rollback: naro rollback always rolls production back."
5653
+ );
5517
5654
  const client = await context.client();
5518
5655
  const projectId = await context.resolveProjectId();
5519
5656
  const project = await client.projects.get(projectId);
@@ -5625,6 +5762,7 @@ var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --priva
5625
5762
  var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
5626
5763
  var REMOVE_USAGE2 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
5627
5764
  var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
5765
+ var DISABLE_QUESTION3 = "Turn Storage off for this project? Its Storage calls and file URLs answer 404 until naro storage enable; buckets and files are kept.";
5628
5766
  var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
5629
5767
  var IDLE_PAGES = 3;
5630
5768
  function bucketLine(bucket) {
@@ -5645,6 +5783,12 @@ function utcClock(iso) {
5645
5783
  const at = new Date(iso);
5646
5784
  return Number.isNaN(at.getTime()) ? null : at.toISOString().slice(11, 16);
5647
5785
  }
5786
+ function utcMinute(iso) {
5787
+ const at = new Date(iso);
5788
+ if (Number.isNaN(at.getTime())) return printable(iso);
5789
+ const text = at.toISOString();
5790
+ return `${text.slice(0, 10)} ${text.slice(11, 16)} UTC`;
5791
+ }
5648
5792
  function warnedPercent(value, limit) {
5649
5793
  if (limit === null || !(limit > 0)) return null;
5650
5794
  const percent2 = Math.floor(value * 100 / limit);
@@ -5686,6 +5830,13 @@ function meterLines(info) {
5686
5830
  return lines;
5687
5831
  }
5688
5832
  function storageLines(info) {
5833
+ if (info.disabledAt !== null)
5834
+ return [
5835
+ `Storage: off since ${utcMinute(info.disabledAt)} \u2014 buckets and files are kept`,
5836
+ "Run `naro storage enable` to turn it on again: the same buckets and files come back.",
5837
+ info.buckets.length === 0 ? "Buckets: none" : `Buckets: ${String(info.buckets.length)}`,
5838
+ ...info.buckets.map(bucketLine)
5839
+ ];
5689
5840
  if (!info.enabled)
5690
5841
  return [
5691
5842
  "Storage: off",
@@ -5714,6 +5865,19 @@ async function enableStorage(client, projectId) {
5714
5865
  throw error;
5715
5866
  }
5716
5867
  }
5868
+ async function disableStorage(client, projectId) {
5869
+ try {
5870
+ return await client.storage.disable(projectId);
5871
+ } catch (error) {
5872
+ if (isNaroError(error) && error.code === "TIMEOUT")
5873
+ throw new NaroError(
5874
+ "TIMEOUT",
5875
+ "Turning Storage off did not answer within 2 minutes. `naro storage` shows where it got to, and `naro storage disable` again picks up where it stopped.",
5876
+ { cause: error }
5877
+ );
5878
+ throw error;
5879
+ }
5880
+ }
5717
5881
  function bucketName(context, usage) {
5718
5882
  if (context.positionals.length !== 1)
5719
5883
  throw new NaroError("VALIDATION_FAILED", usage);
@@ -5750,6 +5914,24 @@ async function runStorageEnable(context) {
5750
5914
  ...storageLines(storage)
5751
5915
  ]);
5752
5916
  }
5917
+ async function runStorageDisable(context) {
5918
+ const client = await context.client();
5919
+ const projectId = await context.resolveProjectId();
5920
+ const current = await client.storage.info(projectId);
5921
+ if (current.bucket === null) {
5922
+ context.output.result({ projectId, storage: current }, [
5923
+ "Storage is already off."
5924
+ ]);
5925
+ return;
5926
+ }
5927
+ if (current.disabledAt === null)
5928
+ await confirmOrAbort(context, DISABLE_QUESTION3);
5929
+ const storage = await disableStorage(client, projectId);
5930
+ context.output.result({ projectId, storage }, [
5931
+ "\u2705 Storage is off. Buckets and files are kept; naro storage enable turns it on again.",
5932
+ ...storageLines(storage)
5933
+ ]);
5934
+ }
5753
5935
  async function runStorageBucketList(context) {
5754
5936
  const client = await context.client();
5755
5937
  const projectId = await context.resolveProjectId();
@@ -8055,6 +8237,7 @@ var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (
8055
8237
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
8056
8238
  var isSignedEntry = (value) => isRecord(value) && typeof value.path === "string" && (value.signedUrl === null || typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX)) && (value.error === null || typeof value.error === "string");
8057
8239
  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";
8240
+ var isMoved = (value) => isRecord(value) && typeof value.message === "string";
8058
8241
  var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
8059
8242
  function listOf(guard) {
8060
8243
  return (value) => Array.isArray(value) && value.every(guard);
@@ -8210,9 +8393,76 @@ var StorageFileApi = class {
8210
8393
  listOf(isFileObject)
8211
8394
  );
8212
8395
  }
8396
+ /**
8397
+ * The body a move and a copy send (Storage 3-1i): the bucket and both
8398
+ * names, as supabase-js sends them. The names are not checked here — they
8399
+ * are in the body, and the backend answers a bad one `INVALID_REQUEST`
8400
+ * (`details.path` "sourceKey" or "destinationKey"), as `remove` does.
8401
+ */
8402
+ #transferBody(fromPath, toPath, options) {
8403
+ const { destinationBucket, upsert } = options;
8404
+ return {
8405
+ bucketId: this.#bucket,
8406
+ sourceKey: fromPath,
8407
+ destinationKey: toPath,
8408
+ ...destinationBucket === void 0 ? {} : { destinationBucket },
8409
+ ...upsert === true ? { upsert: true } : {}
8410
+ };
8411
+ }
8412
+ /**
8413
+ * Renames an object inside its bucket (Storage 3-1i): the same object — its
8414
+ * id and owner — under `toPath`, its old name gone. It never replaces an
8415
+ * object that is there (`OBJECT_EXISTS`), takes files up to 5 GiB − 5 MiB
8416
+ * (`OBJECT_TOO_LARGE`), and needs room in the project for a second copy
8417
+ * until it is done (`STORAGE_QUOTA_EXCEEDED`). Its signed links stop
8418
+ * working at the old name: a link is bound to the name, the object's id
8419
+ * and its bytes, so moving it back can open them again. A backend whose
8420
+ * Storage was turned on before this method came answers `NOT_FOUND`:
8421
+ * update it with `naro backend enable`.
8422
+ */
8423
+ async move(fromPath, toPath, options = {}) {
8424
+ const bucket = encodeBucket(this.#bucket);
8425
+ if (!bucket.ok) return fail(bucket.error);
8426
+ const { destinationBucket } = options ?? {};
8427
+ return this.#json(
8428
+ {
8429
+ method: "POST",
8430
+ path: "/storage/object/move",
8431
+ body: this.#transferBody(
8432
+ fromPath,
8433
+ toPath,
8434
+ destinationBucket === void 0 ? {} : { destinationBucket }
8435
+ )
8436
+ },
8437
+ isMoved
8438
+ );
8439
+ }
8440
+ /**
8441
+ * Copies an object inside its bucket (Storage 3-1i): the source's bytes,
8442
+ * type and cache-control under `toPath` — a new object of the caller's,
8443
+ * or with `upsert: true` the one that is there, which keeps its owner.
8444
+ * Files up to 5 GiB − 5 MiB, within the bucket's limits as they are now.
8445
+ * It answers `{ id, path, fullPath }` — `path` is the name inside the
8446
+ * bucket; supabase-js's copy puts the bucket in front of it. A backend
8447
+ * whose Storage was turned on before this method came answers
8448
+ * `NOT_FOUND`: update it with `naro backend enable`.
8449
+ */
8450
+ async copy(fromPath, toPath, options = {}) {
8451
+ const bucket = encodeBucket(this.#bucket);
8452
+ if (!bucket.ok) return fail(bucket.error);
8453
+ return this.#json(
8454
+ {
8455
+ method: "POST",
8456
+ path: "/storage/object/copy",
8457
+ body: this.#transferBody(fromPath, toPath, options ?? {})
8458
+ },
8459
+ isUploadResult
8460
+ );
8461
+ }
8213
8462
  /**
8214
8463
  * A link to the object for `expiresIn` seconds (1–604,800). It stops
8215
- * working once the object is replaced or removed.
8464
+ * working once the object is replaced or removed, and while the object is
8465
+ * moved away from `path` (moved back, it can open it again).
8216
8466
  */
8217
8467
  async createSignedUrl(path, expiresIn, options = {}) {
8218
8468
  const target = this.#objectPath("sign/", path);
@@ -8503,10 +8753,12 @@ function createClient(options) {
8503
8753
 
8504
8754
  // src/commands/storage-objects.ts
8505
8755
  var NARO_SCHEME = "naro://";
8506
- var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<path>, the other a local file)";
8756
+ var CP_USAGE = "Usage: naro storage cp <from> <to> (a local file and naro://<bucket>/<path>, or two naro://<bucket>/<path> in one bucket)";
8757
+ var MV_USAGE = "Usage: naro storage mv naro://<bucket>/<from> naro://<bucket>/<to>";
8507
8758
  var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
8508
8759
  var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive] [--search <text>]";
8509
8760
  var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
8761
+ var STORAGE_OFF_MESSAGE = "Storage is turned off for this project. Turn it on with `naro storage enable` first.";
8510
8762
  var STORAGE_OUTDATED_MESSAGE = "The project backend is older than this Storage command. Run `naro backend enable` to update it, then try again.";
8511
8763
  var OLD_SEARCH_REASON = "search is not supported yet";
8512
8764
  function isOldSearchRefusal(error) {
@@ -8516,6 +8768,10 @@ function isOldSearchRefusal(error) {
8516
8768
  const { path, reason } = details;
8517
8769
  return path === "search" && reason === OLD_SEARCH_REASON;
8518
8770
  }
8771
+ var NO_ENDPOINT = "There is no Naro endpoint at this path.";
8772
+ function isOldRouteRefusal(error) {
8773
+ return error !== null && error.code === "NOT_FOUND" && error.message === NO_ENDPOINT;
8774
+ }
8519
8775
  var FOUR_GIB = 4294967296;
8520
8776
  var PAGE = 1e3;
8521
8777
  var MAX_OFFSET = 1e5;
@@ -8595,6 +8851,9 @@ async function objects(context) {
8595
8851
  "This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
8596
8852
  );
8597
8853
  if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
8854
+ const state = await client.storage.info(projectId);
8855
+ if (state.disabledAt !== null)
8856
+ throw new NaroError("STORAGE_OFF", STORAGE_OFF_MESSAGE);
8598
8857
  let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
8599
8858
  if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
8600
8859
  throw new NaroError(
@@ -8982,17 +9241,106 @@ async function download(context, source, local) {
8982
9241
  ]
8983
9242
  );
8984
9243
  }
8985
- async function storageCp(context) {
9244
+ function unwrapTransfer(result) {
9245
+ if (isOldRouteRefusal(result.error))
9246
+ throw new NaroError("BACKEND_OUTDATED", STORAGE_OUTDATED_MESSAGE);
9247
+ return unwrap(result);
9248
+ }
9249
+ function refuseBadName(bucket, name) {
9250
+ const route2 = objectRoute(bucket, name);
9251
+ if (!route2.ok) throw new NaroError(route2.error.code, route2.error.message);
9252
+ const checked = checkObjectName(name);
9253
+ if (!checked.ok) {
9254
+ const { code, message } = dataError("INVALID_PATH", {
9255
+ reason: checked.reason
9256
+ });
9257
+ throw new NaroError(code, message);
9258
+ }
9259
+ }
9260
+ function insideStorage(source, target, command) {
9261
+ if (source.bucket !== target.bucket)
9262
+ throw new NaroError(
9263
+ "VALIDATION_FAILED",
9264
+ "naro storage cp and mv work inside one bucket: both sides must be in naro://<bucket>."
9265
+ );
9266
+ if (source.path === "" || source.path.endsWith("/"))
9267
+ throw new NaroError(
9268
+ "VALIDATION_FAILED",
9269
+ `naro storage ${command} ${command === "mv" ? "moves" : "copies"} one object: name it, naro://<bucket>/<path>.`
9270
+ );
9271
+ const name = source.path.slice(source.path.lastIndexOf("/") + 1);
9272
+ const to = target.path === "" || target.path.endsWith("/") ? `${target.path}${name}` : target.path;
9273
+ refuseBadName(source.bucket, source.path);
9274
+ refuseBadName(source.bucket, to);
9275
+ if (to === source.path)
9276
+ throw new NaroError(
9277
+ "VALIDATION_FAILED",
9278
+ `naro storage ${command} needs a different destination: ${printable(`${NARO_SCHEME}${source.bucket}/${source.path}`)} is the source.`
9279
+ );
9280
+ return { bucket: source.bucket, from: source.path, to };
9281
+ }
9282
+ async function copyInside(context, source, target) {
9283
+ const { contentType, cacheControl } = context.flags;
9284
+ if (contentType !== void 0 || cacheControl !== void 0)
9285
+ throw new NaroError(
9286
+ "VALIDATION_FAILED",
9287
+ "A copy inside Storage keeps the object's type and cache-control: --content-type and --cache-control are for uploads."
9288
+ );
9289
+ if (context.flags.recursive === true)
9290
+ throw new NaroError(
9291
+ "VALIDATION_FAILED",
9292
+ "A copy inside Storage takes no --recursive: it copies one object, naro://<bucket>/<path>. --recursive is for naro storage ls and rm."
9293
+ );
9294
+ const { bucket, from, to } = insideStorage(source, target, "cp");
9295
+ const { projectId, storage } = await objects(context);
9296
+ const object = unwrapTransfer(
9297
+ await storage.from(bucket).copy(from, to, { upsert: context.flags.upsert === true })
9298
+ );
9299
+ const fromUrl = `${NARO_SCHEME}${bucket}/${from}`;
9300
+ const toUrl = `${NARO_SCHEME}${bucket}/${to}`;
9301
+ context.output.result({ projectId, from: fromUrl, to: toUrl, object }, [
9302
+ `\u2705 Copied ${printable(fromUrl)} \u2192 ${printable(toUrl)}.`
9303
+ ]);
9304
+ }
9305
+ async function storageMv(context) {
8986
9306
  const [from, to, ...extra] = context.positionals;
8987
9307
  if (from === void 0 || to === void 0 || extra.length > 0)
8988
- throw new NaroError("VALIDATION_FAILED", CP_USAGE);
9308
+ throw new NaroError("VALIDATION_FAILED", MV_USAGE);
8989
9309
  const source = parseStorageUrl(from);
8990
9310
  const target = parseStorageUrl(to);
8991
- if (source !== null && target !== null)
9311
+ if (source === null || target === null)
9312
+ throw new NaroError(
9313
+ "VALIDATION_FAILED",
9314
+ "naro storage mv moves inside Storage: both sides are naro://<bucket>/<path>."
9315
+ );
9316
+ const { upsert, contentType, cacheControl } = context.flags;
9317
+ if (upsert === true || contentType !== void 0 || cacheControl !== void 0)
9318
+ throw new NaroError(
9319
+ "VALIDATION_FAILED",
9320
+ "naro storage mv takes no --upsert, --content-type or --cache-control: a move keeps the object as it is and never replaces one."
9321
+ );
9322
+ if (context.flags.recursive === true)
8992
9323
  throw new NaroError(
8993
9324
  "VALIDATION_FAILED",
8994
- "naro storage cp copies between this machine and Storage: one side is naro://<bucket>/<path>, the other a local file. Copying inside Storage is not supported yet."
9325
+ "naro storage mv takes no --recursive: it moves one object, naro://<bucket>/<path>. --recursive is for naro storage ls and rm."
8995
9326
  );
9327
+ const moved = insideStorage(source, target, "mv");
9328
+ const { projectId, storage } = await objects(context);
9329
+ unwrapTransfer(await storage.from(moved.bucket).move(moved.from, moved.to));
9330
+ const fromUrl = `${NARO_SCHEME}${moved.bucket}/${moved.from}`;
9331
+ const toUrl = `${NARO_SCHEME}${moved.bucket}/${moved.to}`;
9332
+ context.output.result({ projectId, from: fromUrl, to: toUrl }, [
9333
+ `\u2705 Moved ${printable(fromUrl)} \u2192 ${printable(toUrl)}.`
9334
+ ]);
9335
+ }
9336
+ async function storageCp(context) {
9337
+ const [from, to, ...extra] = context.positionals;
9338
+ if (from === void 0 || to === void 0 || extra.length > 0)
9339
+ throw new NaroError("VALIDATION_FAILED", CP_USAGE);
9340
+ const source = parseStorageUrl(from);
9341
+ const target = parseStorageUrl(to);
9342
+ if (source !== null && target !== null)
9343
+ return copyInside(context, source, target);
8996
9344
  if (target !== null) return upload(context, from, target);
8997
9345
  if (source !== null) return download(context, source, to);
8998
9346
  throw new NaroError("VALIDATION_FAILED", CP_USAGE);
@@ -9106,6 +9454,9 @@ function runStorageLs(context) {
9106
9454
  function runStorageCp(context) {
9107
9455
  return explainingKey(context, storageCp);
9108
9456
  }
9457
+ function runStorageMv(context) {
9458
+ return explainingKey(context, storageMv);
9459
+ }
9109
9460
  function runStorageRm(context) {
9110
9461
  return explainingKey(context, storageRm);
9111
9462
  }
@@ -9372,6 +9723,8 @@ Usage:
9372
9723
  naro storage [status] Naro Storage: on or off, limits, what is stored, today's operations, the buckets
9373
9724
  naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
9374
9725
  (safe to re-run; applies a plan change's limits)
9726
+ naro storage disable [--yes] Turn Storage off and keep its files: its calls and file URLs answer 404
9727
+ until naro storage enable binds the same bucket again (asks first)
9375
9728
  naro storage bucket list The buckets: public or private, objects, bytes, limits
9376
9729
  naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]
9377
9730
  <size>: bytes, 5MB (1000s) or 5MiB (1024s); turns Storage on first if needed
@@ -9388,12 +9741,18 @@ Usage:
9388
9741
  One file between this machine and naro://<bucket>/<path>, up to the object limit (over 50 MiB in parts),
9389
9742
  with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
9390
9743
  A big upload shows its progress on stderr; a file of 4 GiB or more cannot be uploaded yet.
9391
- A download replaces a local file of the same name
9744
+ A download replaces a local file of the same name.
9745
+ Two naro:// paths in one bucket: a copy inside Storage, up to 5 GiB \u2212 5 MiB (--upsert replaces)
9746
+ naro storage mv naro://<bucket>/<from> naro://<bucket>/<to>
9747
+ Move an object inside its bucket, up to 5 GiB \u2212 5 MiB; it never replaces one that is there
9392
9748
  naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
9393
9749
  Remove objects; --recursive removes what is under a folder (asks first):
9394
9750
  <path>/ is the folder alone, <path> also an object of that name
9395
9751
 
9396
- naro promote <deployment-id> Publish a build to production
9752
+ naro promote <deployment-id> [--to <environment>]
9753
+ Publish a build to production, or to an environment with --to; it says what it will do, then asks.
9754
+ A build moving between an environment and another surface is built again from its commit with the
9755
+ target's variables, which takes up to 15 minutes; any other build is published as stored.
9397
9756
  naro rollback [deployment-id] Roll production back to the previous build
9398
9757
 
9399
9758
  Global options:
@@ -9417,6 +9776,7 @@ Global options:
9417
9776
  --device <d> Only this device: desktop, mobile or tablet (naro analytics, naro insights)
9418
9777
  --browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
9419
9778
  --os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
9779
+ --to <environment> production (the default) or an environment name: where naro promote publishes the build (naro promote)
9420
9780
  --env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name)
9421
9781
  --branch <b> naro environments create, set: the branch whose pushes deploy to that environment. naro deploy: the default
9422
9782
  branch of a project this deploy creates or updates (the deploy's own branch follows where it goes)
@@ -9458,8 +9818,8 @@ Global options:
9458
9818
  --recursive, -r Everything under a folder (naro storage ls, naro storage rm)
9459
9819
  --search <text> Names below the prefix that start with <text>, ASCII case ignored (naro storage ls)
9460
9820
  --upsert Replace an object that is there (naro storage cp)
9461
- --content-type <t> The object's type; by default from the file's extension (naro storage cp)
9462
- --cache-control <seconds> How long a browser may keep it; 3600 by default (naro storage cp)
9821
+ --content-type <t> The uploaded object's type; by default from the file's extension (naro storage cp, uploads only)
9822
+ --cache-control <seconds> How long a browser may keep an upload; 3600 by default (naro storage cp, uploads only)
9463
9823
  --select <rule> Rule for reads; --insert, --update, --delete likewise (naro policy set)
9464
9824
  --owner <column> Column that holds the signed-in user's id, for the owner rule (naro policy set, db table create)
9465
9825
  --select-when <filter> Condition for reads, e.g. 'user_id.eq.auth.uid()'; --insert-when (what a new
@@ -9561,6 +9921,8 @@ function routeStorage(words) {
9561
9921
  return words.length <= 1 ? { handler: runStorageStatus, positionals: [] } : null;
9562
9922
  case "enable":
9563
9923
  return words.length === 1 ? { handler: runStorageEnable, positionals: [] } : null;
9924
+ case "disable":
9925
+ return words.length === 1 ? { handler: runStorageDisable, positionals: [] } : null;
9564
9926
  case "bucket":
9565
9927
  case "buckets":
9566
9928
  switch (action) {
@@ -9597,13 +9959,24 @@ function routeStorage(words) {
9597
9959
  return { handler: runStorageLs, positionals: words.slice(1) };
9598
9960
  case "cp":
9599
9961
  return { handler: runStorageCp, positionals: words.slice(1) };
9962
+ case "mv":
9963
+ return { handler: runStorageMv, positionals: words.slice(1) };
9600
9964
  case "rm":
9601
9965
  return { handler: runStorageRm, positionals: words.slice(1) };
9602
9966
  default:
9603
9967
  return null;
9604
9968
  }
9605
9969
  }
9970
+ async function refuseTo() {
9971
+ throw new NaroError("VALIDATION_FAILED", "--to is only for naro promote.");
9972
+ }
9606
9973
  function route(parsed) {
9974
+ const routed = routeCommand(parsed);
9975
+ if (routed && parsed.flags.to !== void 0 && parsed.command !== "promote" && parsed.command !== "rollback")
9976
+ return { handler: refuseTo, positionals: routed.positionals };
9977
+ return routed;
9978
+ }
9979
+ function routeCommand(parsed) {
9607
9980
  const { command, positionals } = parsed;
9608
9981
  const sub = positionals[0];
9609
9982
  switch (command) {