naro-cli 0.19.0 → 0.19.1

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/deploy.d.ts CHANGED
@@ -55,9 +55,9 @@ export interface DeployOptions {
55
55
  production?: boolean;
56
56
  /**
57
57
  * Deploy to this named environment (its slug) instead of preview. Not with
58
- * `production` (the control plane refuses both), and not `production` or
59
- * `preview` themselves: those are `production: true` and the default, and
60
- * are refused with VALIDATION_FAILED before anything runs.
58
+ * `production`, and not `production` or `preview` themselves: those are
59
+ * `production: true` and the default. Each is refused with
60
+ * VALIDATION_FAILED before any request.
61
61
  */
62
62
  environment?: string | undefined;
63
63
  env?: Record<string, string> | undefined;
package/dist/deploy.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  deployDirectory,
4
4
  eventsObserver,
5
5
  localDependencies,
6
- parseDotenv2 as parseDotenv,
6
+ parseDotenv,
7
7
  readProjectEnv,
8
8
  slugify,
9
9
  workingTreeNotices
10
- } from "./chunk-DEEFHVDZ.js";
10
+ } from "./chunk-ZO2BEC46.js";
11
11
  import "./chunk-K3NQKI34.js";
12
12
  export {
13
13
  deployDirectory,
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,
@@ -71,6 +74,7 @@ import {
71
74
  resetFileRefusedMessage,
72
75
  resetPoliciesLostMessage,
73
76
  resetPoliciesWithTablesMessage,
77
+ resetStorageOutdatedMessage,
74
78
  resolveAuth,
75
79
  resolveMigrationsDir,
76
80
  saveSession,
@@ -80,6 +84,7 @@ import {
80
84
  snapshotFromRows,
81
85
  splitPolicyStatements,
82
86
  splitStatements,
87
+ storageRefusalOfFiles,
83
88
  storedPolicySql,
84
89
  summarizeStatement,
85
90
  summarizeTeamUsage,
@@ -91,7 +96,7 @@ import {
91
96
  usageMonthOf,
92
97
  userForAccessToken,
93
98
  writeProjectLink
94
- } from "./chunk-DEEFHVDZ.js";
99
+ } from "./chunk-ZO2BEC46.js";
95
100
  import "./chunk-K3NQKI34.js";
96
101
 
97
102
  // src/args.ts
@@ -2644,12 +2649,45 @@ async function hasBackend(client, projectId) {
2644
2649
  throw error;
2645
2650
  }
2646
2651
  }
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: [] };
2652
+ async function refuseStorageOutdated(client, projectId, storageTable, files, texts) {
2653
+ let refused = storageRefusalOfFiles(texts, {
2654
+ storage: storageTable,
2655
+ storageSubqueries: false
2656
+ });
2657
+ if (refused?.needs === "subqueries" && storageTable) {
2658
+ const answer = await client.db.query(projectId, {
2659
+ sql: STORAGE_SUBQUERIES_SQL
2660
+ });
2661
+ if ((answer.statements[0]?.rows.length ?? 0) > 0)
2662
+ refused = storageRefusalOfFiles(texts, {
2663
+ storage: true,
2664
+ storageSubqueries: true
2665
+ });
2652
2666
  }
2667
+ if (refused)
2668
+ throw new NaroError(
2669
+ "BACKEND_OUTDATED",
2670
+ resetStorageOutdatedMessage(files[refused.file] ?? "", refused),
2671
+ {
2672
+ details: {
2673
+ migration: files[refused.file] ?? "",
2674
+ statement: refused.statement
2675
+ }
2676
+ }
2677
+ );
2678
+ }
2679
+ async function policyPlan(client, projectId, schema, files, texts) {
2680
+ if (!schema.policyTable && await hasBackend(client, projectId))
2681
+ throw new NaroError("BACKEND_OUTDATED", RESET_BACKEND_OUTDATED);
2682
+ await refuseStorageOutdated(
2683
+ client,
2684
+ projectId,
2685
+ schema.storageTable,
2686
+ files,
2687
+ texts
2688
+ );
2689
+ if (!schema.policyTable)
2690
+ return { clearPolicies: false, lost: [], withTables: [] };
2653
2691
  const answer = await client.db.query(projectId, {
2654
2692
  sql: NAMED_POLICY_SQL.list
2655
2693
  });
@@ -2678,7 +2716,7 @@ async function policyPlan(client, projectId, schema, dir, files) {
2678
2716
  ...policiesLostByReset({
2679
2717
  current,
2680
2718
  tables: schema.tables,
2681
- files: files.map((name) => readFileSync3(join3(dir, name), "utf8")),
2719
+ files: texts,
2682
2720
  // Storage's table stays; its policies are emptied with the rest (PR
2683
2721
  // #85 리뷰). Realtime's messages need no flag: a virtual target, which
2684
2722
  // reset-plan counts as a table always there (Realtime 3-2a).
@@ -2716,6 +2754,7 @@ async function runDbReset(context) {
2716
2754
  ${problemLines.join("\n")}`,
2717
2755
  { details: { problems } }
2718
2756
  );
2757
+ const texts = files.map((name) => readFileSync3(join3(dir, name), "utf8"));
2719
2758
  const client = await context.client();
2720
2759
  const projectId = await context.resolveProjectId();
2721
2760
  const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
@@ -2731,8 +2770,8 @@ ${problemLines.join("\n")}`,
2731
2770
  client,
2732
2771
  projectId,
2733
2772
  { policyTable: policyTable2, storageTable, tables },
2734
- dir,
2735
- files
2773
+ files,
2774
+ texts
2736
2775
  );
2737
2776
  const policiesNotInFiles = policies.lost.map((policy) => ({
2738
2777
  name: `${policy.table}.${policy.name}`,
@@ -4074,13 +4113,16 @@ async function runGitConnect(context, overrides = {}) {
4074
4113
  if (cannotAsk(context)) {
4075
4114
  throw new NaroError(
4076
4115
  "REPOSITORY_NOT_ACCESSIBLE",
4077
- `Naro cannot reach ${fullName} yet. Install the Naro GitHub App at ${first.installUrl}, then run: naro git connect`,
4116
+ `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
4117
  { details: { projectId, installUrl: first.installUrl } }
4079
4118
  );
4080
4119
  }
4081
4120
  output.log(`
4082
4121
  \u25B8 Naro needs access to ${fullName}.`);
4083
4122
  output.log(` Opening ${first.installUrl}`);
4123
+ output.log(
4124
+ " If the repository is not among the ones the Naro GitHub App was granted on GitHub, add it there first."
4125
+ );
4084
4126
  deps.openBrowser(first.installUrl);
4085
4127
  output.log(" Waiting for the installation to finish...\n");
4086
4128
  await waitForInstall(context, projectId, fullName, deps);
@@ -4848,6 +4890,7 @@ var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4848
4890
  var KEY_MAX_LENGTH = 256;
4849
4891
  var IMPORT_BATCH_SIZE = 100;
4850
4892
  var VALUE_MAX_BYTES = 5 * 1024;
4893
+ var STATIC_ASSETS_BINDING = "ASSETS";
4851
4894
  var TARGET_USAGE = "[production|preview|all|<environment>]";
4852
4895
  function validKey(key) {
4853
4896
  return KEY_RE.test(key) && key.length <= KEY_MAX_LENGTH;
@@ -4960,7 +5003,14 @@ async function runEnvImport(context) {
4960
5003
  } catch {
4961
5004
  throw new NaroError("VALIDATION_FAILED", `Cannot read ${file}.`);
4962
5005
  }
4963
- const vars = Object.entries(parseDotenv(text));
5006
+ const parsed = parseDotenvDetailed(text);
5007
+ const [unclosed] = parsed.unclosedQuotes;
5008
+ if (unclosed !== void 0)
5009
+ throw new NaroError(
5010
+ "VALIDATION_FAILED",
5011
+ `Unclosed quote in ${file} at line ${String(unclosed)}.`
5012
+ );
5013
+ const vars = Object.entries(parsed.vars);
4964
5014
  if (vars.length === 0)
4965
5015
  throw new NaroError("VALIDATION_FAILED", `No variables found in ${file}.`);
4966
5016
  const invalid = vars.map(([key]) => key).filter((key) => !validKey(key));
@@ -4969,6 +5019,11 @@ async function runEnvImport(context) {
4969
5019
  "VALIDATION_FAILED",
4970
5020
  `Invalid keys in ${file}: ${invalid.join(", ")}.`
4971
5021
  );
5022
+ if (vars.some(([key]) => key === STATIC_ASSETS_BINDING))
5023
+ throw new NaroError(
5024
+ "VALIDATION_FAILED",
5025
+ `${STATIC_ASSETS_BINDING} is reserved for the project's static files; pick another name.`
5026
+ );
4972
5027
  const oversized = vars.filter(([, value]) => Buffer.byteLength(value, "utf8") > VALUE_MAX_BYTES).map(([key]) => key);
4973
5028
  if (oversized.length > 0)
4974
5029
  throw new NaroError(
@@ -4978,7 +5033,9 @@ async function runEnvImport(context) {
4978
5033
  const projectId = await context.resolveProjectId();
4979
5034
  context.output.log(`Variables in ${file}:`);
4980
5035
  for (const [key, value] of vars)
4981
- context.output.log(` ${key.padEnd(32)} ${maskEnvValue(key, value)}`);
5036
+ context.output.log(
5037
+ ` ${key.padEnd(32)} ${value === "" ? "(empty)" : maskEnvValue(key, value)}`
5038
+ );
4982
5039
  await confirmOrAbort(
4983
5040
  context,
4984
5041
  `Upload ${variables(vars.length)} to ${target}?`
@@ -4990,6 +5047,15 @@ async function runEnvImport(context) {
4990
5047
  target,
4991
5048
  isSecret: isSecretKey(key)
4992
5049
  }));
5050
+ if (entries.length > IMPORT_BATCH_SIZE) {
5051
+ const taken = new Set(await client.env.takenBindingNames(projectId));
5052
+ const conflict = entries.find((entry) => taken.has(entry.key));
5053
+ if (conflict)
5054
+ throw new NaroError(
5055
+ "VALIDATION_FAILED",
5056
+ `${conflict.key} is already used by a storage binding.`
5057
+ );
5058
+ }
4993
5059
  for (let start = 0; start < entries.length; start += IMPORT_BATCH_SIZE)
4994
5060
  await client.env.set(
4995
5061
  projectId,
@@ -5017,17 +5083,14 @@ async function runEnvPull(context) {
5017
5083
  const rows = await client.env.list(projectId, { reveal: true });
5018
5084
  const vars = pullEnv(rows, surface);
5019
5085
  const count2 = Object.keys(vars).length;
5086
+ const text = `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
5087
+ ${serializeDotenv(vars)}`;
5020
5088
  const path = resolve4(context.cwd, file);
5021
5089
  if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
5022
5090
  const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
5023
5091
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
5024
5092
  }
5025
- writeFileSync4(
5026
- path,
5027
- `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
5028
- ${serializeDotenv(vars)}`,
5029
- { mode: 384 }
5030
- );
5093
+ writeFileSync4(path, text, { mode: 384 });
5031
5094
  chmodSync(path, 384);
5032
5095
  context.output.result({ projectId, file, environment: surface, count: count2 }, [
5033
5096
  `\u2705 Pulled ${String(count2)} variable(s) into ${file}`
@@ -8055,6 +8118,7 @@ var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (
8055
8118
  var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
8056
8119
  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
8120
  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";
8121
+ var isMoved = (value) => isRecord(value) && typeof value.message === "string";
8058
8122
  var partsBody = (body) => isArrayBuffer(body) ? new Uint8Array(body) : body;
8059
8123
  function listOf(guard) {
8060
8124
  return (value) => Array.isArray(value) && value.every(guard);
@@ -8210,9 +8274,76 @@ var StorageFileApi = class {
8210
8274
  listOf(isFileObject)
8211
8275
  );
8212
8276
  }
8277
+ /**
8278
+ * The body a move and a copy send (Storage 3-1i): the bucket and both
8279
+ * names, as supabase-js sends them. The names are not checked here — they
8280
+ * are in the body, and the backend answers a bad one `INVALID_REQUEST`
8281
+ * (`details.path` "sourceKey" or "destinationKey"), as `remove` does.
8282
+ */
8283
+ #transferBody(fromPath, toPath, options) {
8284
+ const { destinationBucket, upsert } = options;
8285
+ return {
8286
+ bucketId: this.#bucket,
8287
+ sourceKey: fromPath,
8288
+ destinationKey: toPath,
8289
+ ...destinationBucket === void 0 ? {} : { destinationBucket },
8290
+ ...upsert === true ? { upsert: true } : {}
8291
+ };
8292
+ }
8293
+ /**
8294
+ * Renames an object inside its bucket (Storage 3-1i): the same object — its
8295
+ * id and owner — under `toPath`, its old name gone. It never replaces an
8296
+ * object that is there (`OBJECT_EXISTS`), takes files up to 5 GiB − 5 MiB
8297
+ * (`OBJECT_TOO_LARGE`), and needs room in the project for a second copy
8298
+ * until it is done (`STORAGE_QUOTA_EXCEEDED`). Its signed links stop
8299
+ * working at the old name: a link is bound to the name, the object's id
8300
+ * and its bytes, so moving it back can open them again. A backend whose
8301
+ * Storage was turned on before this method came answers `NOT_FOUND`:
8302
+ * update it with `naro backend enable`.
8303
+ */
8304
+ async move(fromPath, toPath, options = {}) {
8305
+ const bucket = encodeBucket(this.#bucket);
8306
+ if (!bucket.ok) return fail(bucket.error);
8307
+ const { destinationBucket } = options ?? {};
8308
+ return this.#json(
8309
+ {
8310
+ method: "POST",
8311
+ path: "/storage/object/move",
8312
+ body: this.#transferBody(
8313
+ fromPath,
8314
+ toPath,
8315
+ destinationBucket === void 0 ? {} : { destinationBucket }
8316
+ )
8317
+ },
8318
+ isMoved
8319
+ );
8320
+ }
8321
+ /**
8322
+ * Copies an object inside its bucket (Storage 3-1i): the source's bytes,
8323
+ * type and cache-control under `toPath` — a new object of the caller's,
8324
+ * or with `upsert: true` the one that is there, which keeps its owner.
8325
+ * Files up to 5 GiB − 5 MiB, within the bucket's limits as they are now.
8326
+ * It answers `{ id, path, fullPath }` — `path` is the name inside the
8327
+ * bucket; supabase-js's copy puts the bucket in front of it. A backend
8328
+ * whose Storage was turned on before this method came answers
8329
+ * `NOT_FOUND`: update it with `naro backend enable`.
8330
+ */
8331
+ async copy(fromPath, toPath, options = {}) {
8332
+ const bucket = encodeBucket(this.#bucket);
8333
+ if (!bucket.ok) return fail(bucket.error);
8334
+ return this.#json(
8335
+ {
8336
+ method: "POST",
8337
+ path: "/storage/object/copy",
8338
+ body: this.#transferBody(fromPath, toPath, options ?? {})
8339
+ },
8340
+ isUploadResult
8341
+ );
8342
+ }
8213
8343
  /**
8214
8344
  * A link to the object for `expiresIn` seconds (1–604,800). It stops
8215
- * working once the object is replaced or removed.
8345
+ * working once the object is replaced or removed, and while the object is
8346
+ * moved away from `path` (moved back, it can open it again).
8216
8347
  */
8217
8348
  async createSignedUrl(path, expiresIn, options = {}) {
8218
8349
  const target = this.#objectPath("sign/", path);
@@ -8503,7 +8634,8 @@ function createClient(options) {
8503
8634
 
8504
8635
  // src/commands/storage-objects.ts
8505
8636
  var NARO_SCHEME = "naro://";
8506
- var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<path>, the other a local file)";
8637
+ var CP_USAGE = "Usage: naro storage cp <from> <to> (a local file and naro://<bucket>/<path>, or two naro://<bucket>/<path> in one bucket)";
8638
+ var MV_USAGE = "Usage: naro storage mv naro://<bucket>/<from> naro://<bucket>/<to>";
8507
8639
  var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
8508
8640
  var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive] [--search <text>]";
8509
8641
  var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
@@ -8516,6 +8648,10 @@ function isOldSearchRefusal(error) {
8516
8648
  const { path, reason } = details;
8517
8649
  return path === "search" && reason === OLD_SEARCH_REASON;
8518
8650
  }
8651
+ var NO_ENDPOINT = "There is no Naro endpoint at this path.";
8652
+ function isOldRouteRefusal(error) {
8653
+ return error !== null && error.code === "NOT_FOUND" && error.message === NO_ENDPOINT;
8654
+ }
8519
8655
  var FOUR_GIB = 4294967296;
8520
8656
  var PAGE = 1e3;
8521
8657
  var MAX_OFFSET = 1e5;
@@ -8982,17 +9118,106 @@ async function download(context, source, local) {
8982
9118
  ]
8983
9119
  );
8984
9120
  }
8985
- async function storageCp(context) {
9121
+ function unwrapTransfer(result) {
9122
+ if (isOldRouteRefusal(result.error))
9123
+ throw new NaroError("BACKEND_OUTDATED", STORAGE_OUTDATED_MESSAGE);
9124
+ return unwrap(result);
9125
+ }
9126
+ function refuseBadName(bucket, name) {
9127
+ const route2 = objectRoute(bucket, name);
9128
+ if (!route2.ok) throw new NaroError(route2.error.code, route2.error.message);
9129
+ const checked = checkObjectName(name);
9130
+ if (!checked.ok) {
9131
+ const { code, message } = dataError("INVALID_PATH", {
9132
+ reason: checked.reason
9133
+ });
9134
+ throw new NaroError(code, message);
9135
+ }
9136
+ }
9137
+ function insideStorage(source, target, command) {
9138
+ if (source.bucket !== target.bucket)
9139
+ throw new NaroError(
9140
+ "VALIDATION_FAILED",
9141
+ "naro storage cp and mv work inside one bucket: both sides must be in naro://<bucket>."
9142
+ );
9143
+ if (source.path === "" || source.path.endsWith("/"))
9144
+ throw new NaroError(
9145
+ "VALIDATION_FAILED",
9146
+ `naro storage ${command} ${command === "mv" ? "moves" : "copies"} one object: name it, naro://<bucket>/<path>.`
9147
+ );
9148
+ const name = source.path.slice(source.path.lastIndexOf("/") + 1);
9149
+ const to = target.path === "" || target.path.endsWith("/") ? `${target.path}${name}` : target.path;
9150
+ refuseBadName(source.bucket, source.path);
9151
+ refuseBadName(source.bucket, to);
9152
+ if (to === source.path)
9153
+ throw new NaroError(
9154
+ "VALIDATION_FAILED",
9155
+ `naro storage ${command} needs a different destination: ${printable(`${NARO_SCHEME}${source.bucket}/${source.path}`)} is the source.`
9156
+ );
9157
+ return { bucket: source.bucket, from: source.path, to };
9158
+ }
9159
+ async function copyInside(context, source, target) {
9160
+ const { contentType, cacheControl } = context.flags;
9161
+ if (contentType !== void 0 || cacheControl !== void 0)
9162
+ throw new NaroError(
9163
+ "VALIDATION_FAILED",
9164
+ "A copy inside Storage keeps the object's type and cache-control: --content-type and --cache-control are for uploads."
9165
+ );
9166
+ if (context.flags.recursive === true)
9167
+ throw new NaroError(
9168
+ "VALIDATION_FAILED",
9169
+ "A copy inside Storage takes no --recursive: it copies one object, naro://<bucket>/<path>. --recursive is for naro storage ls and rm."
9170
+ );
9171
+ const { bucket, from, to } = insideStorage(source, target, "cp");
9172
+ const { projectId, storage } = await objects(context);
9173
+ const object = unwrapTransfer(
9174
+ await storage.from(bucket).copy(from, to, { upsert: context.flags.upsert === true })
9175
+ );
9176
+ const fromUrl = `${NARO_SCHEME}${bucket}/${from}`;
9177
+ const toUrl = `${NARO_SCHEME}${bucket}/${to}`;
9178
+ context.output.result({ projectId, from: fromUrl, to: toUrl, object }, [
9179
+ `\u2705 Copied ${printable(fromUrl)} \u2192 ${printable(toUrl)}.`
9180
+ ]);
9181
+ }
9182
+ async function storageMv(context) {
8986
9183
  const [from, to, ...extra] = context.positionals;
8987
9184
  if (from === void 0 || to === void 0 || extra.length > 0)
8988
- throw new NaroError("VALIDATION_FAILED", CP_USAGE);
9185
+ throw new NaroError("VALIDATION_FAILED", MV_USAGE);
8989
9186
  const source = parseStorageUrl(from);
8990
9187
  const target = parseStorageUrl(to);
8991
- if (source !== null && target !== null)
9188
+ if (source === null || target === null)
9189
+ throw new NaroError(
9190
+ "VALIDATION_FAILED",
9191
+ "naro storage mv moves inside Storage: both sides are naro://<bucket>/<path>."
9192
+ );
9193
+ const { upsert, contentType, cacheControl } = context.flags;
9194
+ if (upsert === true || contentType !== void 0 || cacheControl !== void 0)
8992
9195
  throw new NaroError(
8993
9196
  "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."
9197
+ "naro storage mv takes no --upsert, --content-type or --cache-control: a move keeps the object as it is and never replaces one."
8995
9198
  );
9199
+ if (context.flags.recursive === true)
9200
+ throw new NaroError(
9201
+ "VALIDATION_FAILED",
9202
+ "naro storage mv takes no --recursive: it moves one object, naro://<bucket>/<path>. --recursive is for naro storage ls and rm."
9203
+ );
9204
+ const moved = insideStorage(source, target, "mv");
9205
+ const { projectId, storage } = await objects(context);
9206
+ unwrapTransfer(await storage.from(moved.bucket).move(moved.from, moved.to));
9207
+ const fromUrl = `${NARO_SCHEME}${moved.bucket}/${moved.from}`;
9208
+ const toUrl = `${NARO_SCHEME}${moved.bucket}/${moved.to}`;
9209
+ context.output.result({ projectId, from: fromUrl, to: toUrl }, [
9210
+ `\u2705 Moved ${printable(fromUrl)} \u2192 ${printable(toUrl)}.`
9211
+ ]);
9212
+ }
9213
+ async function storageCp(context) {
9214
+ const [from, to, ...extra] = context.positionals;
9215
+ if (from === void 0 || to === void 0 || extra.length > 0)
9216
+ throw new NaroError("VALIDATION_FAILED", CP_USAGE);
9217
+ const source = parseStorageUrl(from);
9218
+ const target = parseStorageUrl(to);
9219
+ if (source !== null && target !== null)
9220
+ return copyInside(context, source, target);
8996
9221
  if (target !== null) return upload(context, from, target);
8997
9222
  if (source !== null) return download(context, source, to);
8998
9223
  throw new NaroError("VALIDATION_FAILED", CP_USAGE);
@@ -9106,6 +9331,9 @@ function runStorageLs(context) {
9106
9331
  function runStorageCp(context) {
9107
9332
  return explainingKey(context, storageCp);
9108
9333
  }
9334
+ function runStorageMv(context) {
9335
+ return explainingKey(context, storageMv);
9336
+ }
9109
9337
  function runStorageRm(context) {
9110
9338
  return explainingKey(context, storageRm);
9111
9339
  }
@@ -9388,7 +9616,10 @@ Usage:
9388
9616
  One file between this machine and naro://<bucket>/<path>, up to the object limit (over 50 MiB in parts),
9389
9617
  with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
9390
9618
  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
9619
+ A download replaces a local file of the same name.
9620
+ Two naro:// paths in one bucket: a copy inside Storage, up to 5 GiB \u2212 5 MiB (--upsert replaces)
9621
+ naro storage mv naro://<bucket>/<from> naro://<bucket>/<to>
9622
+ Move an object inside its bucket, up to 5 GiB \u2212 5 MiB; it never replaces one that is there
9392
9623
  naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
9393
9624
  Remove objects; --recursive removes what is under a folder (asks first):
9394
9625
  <path>/ is the folder alone, <path> also an object of that name
@@ -9458,8 +9689,8 @@ Global options:
9458
9689
  --recursive, -r Everything under a folder (naro storage ls, naro storage rm)
9459
9690
  --search <text> Names below the prefix that start with <text>, ASCII case ignored (naro storage ls)
9460
9691
  --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)
9692
+ --content-type <t> The uploaded object's type; by default from the file's extension (naro storage cp, uploads only)
9693
+ --cache-control <seconds> How long a browser may keep an upload; 3600 by default (naro storage cp, uploads only)
9463
9694
  --select <rule> Rule for reads; --insert, --update, --delete likewise (naro policy set)
9464
9695
  --owner <column> Column that holds the signed-in user's id, for the owner rule (naro policy set, db table create)
9465
9696
  --select-when <filter> Condition for reads, e.g. 'user_id.eq.auth.uid()'; --insert-when (what a new
@@ -9597,6 +9828,8 @@ function routeStorage(words) {
9597
9828
  return { handler: runStorageLs, positionals: words.slice(1) };
9598
9829
  case "cp":
9599
9830
  return { handler: runStorageCp, positionals: words.slice(1) };
9831
+ case "mv":
9832
+ return { handler: runStorageMv, positionals: words.slice(1) };
9600
9833
  case "rm":
9601
9834
  return { handler: runStorageRm, positionals: words.slice(1) };
9602
9835
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-cli",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "naro": "./dist/index.js"
@@ -33,11 +33,11 @@
33
33
  "tsx": "4.23.12",
34
34
  "typescript": "7.0.2",
35
35
  "vitest": "3.2.7",
36
- "naro-js": "0.5.0",
37
- "@next-deploy/sdk": "0.1.0",
36
+ "naro-js": "0.6.0",
37
+ "@next-deploy/build": "0.1.0",
38
38
  "@next-deploy/core": "0.1.0",
39
39
  "@next-deploy/config": "0.1.0",
40
- "@next-deploy/build": "0.1.0",
40
+ "@next-deploy/sdk": "0.1.0",
41
41
  "@next-deploy/data-core": "0.1.0"
42
42
  },
43
43
  "scripts": {