naro-cli 0.8.0 → 0.9.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/index.js CHANGED
@@ -3,25 +3,39 @@ import {
3
3
  BOOKMARK_FORMAT_MESSAGE,
4
4
  NaroClient,
5
5
  NaroError,
6
+ POLICY_ACTIONS,
7
+ POLICY_RULES,
6
8
  RESTORE_OUTCOME_UNKNOWN_CODES,
9
+ buildResetPlan,
10
+ cellText,
7
11
  clearSession,
8
12
  compareMigrations,
9
13
  controlPlaneUrl,
14
+ countLintedTables,
10
15
  createAuthClient,
11
16
  deployDirectory,
12
17
  detectGitInfo,
13
18
  ensureGitignoreHasNaro,
19
+ formatRedirectEntry,
14
20
  generateTypes,
15
21
  isBookmark,
16
22
  isExpired,
17
23
  isNaroError,
24
+ isStalledBackend,
18
25
  isTerminalStatus,
26
+ lintSchema,
19
27
  listMigrationFiles,
20
28
  loadStoredSession,
21
29
  login,
30
+ migrationNameMismatch,
31
+ migrationsPendingError,
22
32
  nextMigrationFileName,
23
33
  openBrowser,
24
34
  parseGitHubRemote,
35
+ parsePolicyNode,
36
+ parseRedirectEntry,
37
+ pendingMigrationNames,
38
+ policiesForTypes,
25
39
  projectPath,
26
40
  promptForMissingEnv,
27
41
  readProjectLink,
@@ -31,12 +45,15 @@ import {
31
45
  resolveAuth,
32
46
  resolveMigrationsDir,
33
47
  saveSession,
48
+ schemaMigrationTitle,
34
49
  serializeDotenv,
35
50
  slugify,
36
51
  summarizeStatement,
52
+ systemTablePredicate,
37
53
  toNaroError,
38
54
  writeProjectLink
39
- } from "./chunk-ONALKL7O.js";
55
+ } from "./chunk-Z757IOF3.js";
56
+ import "./chunk-K3NQKI34.js";
40
57
 
41
58
  // src/args.ts
42
59
  var BOOLEAN_FLAGS = {
@@ -56,8 +73,20 @@ var BOOLEAN_FLAGS = {
56
73
  "--wait": "wait",
57
74
  "--no-www": "noWww",
58
75
  "--dry-run": "dryRun",
76
+ "--strict": "strict",
59
77
  "--schema-only": "schemaOnly",
60
- "--data-only": "dataOnly"
78
+ "--data-only": "dataOnly",
79
+ "--anon": "anon",
80
+ "--service": "service",
81
+ "--if-not-exists": "ifNotExists",
82
+ "--unique": "unique",
83
+ "--no-write": "noWrite",
84
+ "--signed-in": "signedIn",
85
+ "--api-key-stdin": "apiKeyStdin",
86
+ "--client-secret-stdin": "clientSecretStdin"
87
+ };
88
+ var REPEATED_FLAGS = {
89
+ "--column": "columns"
61
90
  };
62
91
  var STRING_FLAGS = {
63
92
  "--token": "token",
@@ -72,11 +101,40 @@ var STRING_FLAGS = {
72
101
  "--level": "level",
73
102
  "--file": "file",
74
103
  "--output": "output",
104
+ "--lang": "lang",
75
105
  "--dir": "dir",
76
106
  "--table": "table",
77
107
  "--timestamp": "timestamp",
78
- "--bookmark": "bookmark"
108
+ "--bookmark": "bookmark",
109
+ "--status": "status",
110
+ "--select": "selectRule",
111
+ "--insert": "insertRule",
112
+ "--update": "updateRule",
113
+ "--delete": "deleteRule",
114
+ "--owner": "ownerColumn",
115
+ "--select-when": "selectWhen",
116
+ "--insert-when": "insertWhen",
117
+ "--update-when": "updateWhen",
118
+ "--update-check": "updateCheck",
119
+ "--delete-when": "deleteWhen",
120
+ "--from-file": "fromFile",
121
+ "--provider": "provider",
122
+ "--from": "from",
123
+ "--client-id": "clientId"
124
+ };
125
+ var SECRET_FLAGS = {
126
+ "--api-key": "--api-key-stdin",
127
+ "--client-secret": "--client-secret-stdin"
79
128
  };
129
+ function secretFlagProblem(unknown) {
130
+ for (const flag of unknown) {
131
+ const name = flag.split("=")[0]?.split(" ")[0] ?? "";
132
+ const instead = SECRET_FLAGS[name];
133
+ if (instead)
134
+ return `Secrets are read from stdin, never from the command line: pass ${instead} and pipe the value in.`;
135
+ }
136
+ return null;
137
+ }
80
138
  function listFlag(value) {
81
139
  if (value === void 0) return [];
82
140
  return value.split(",").map((piece) => piece.trim()).filter((piece) => piece.length > 0);
@@ -105,6 +163,22 @@ function parseArgs(argv) {
105
163
  continue;
106
164
  }
107
165
  const eq = arg.indexOf("=");
166
+ const repeatedKey = REPEATED_FLAGS[arg] ?? (eq > 0 ? REPEATED_FLAGS[arg.slice(0, eq)] : void 0);
167
+ if (repeatedKey) {
168
+ let value;
169
+ if (eq > 0 && REPEATED_FLAGS[arg.slice(0, eq)]) value = arg.slice(eq + 1);
170
+ else {
171
+ const next = argv[i + 1];
172
+ if (next === void 0 || next.startsWith("-") && next !== "-") {
173
+ unknown.push(`${arg} (missing value)`);
174
+ continue;
175
+ }
176
+ i += 1;
177
+ value = next;
178
+ }
179
+ flags[repeatedKey] = [...flags[repeatedKey] ?? [], value];
180
+ continue;
181
+ }
108
182
  const stringKey = STRING_FLAGS[arg] ?? (eq > 0 ? STRING_FLAGS[arg.slice(0, eq)] : void 0);
109
183
  if (stringKey) {
110
184
  if (eq > 0 && STRING_FLAGS[arg.slice(0, eq)]) {
@@ -221,7 +295,7 @@ function isInteractive() {
221
295
  }
222
296
  async function readStdin() {
223
297
  let data = "";
224
- for await (const chunk of process.stdin) data += String(chunk);
298
+ for await (const chunk2 of process.stdin) data += String(chunk2);
225
299
  return data.replace(/\r?\n$/, "");
226
300
  }
227
301
  var TtyPrompter = class {
@@ -232,7 +306,7 @@ var TtyPrompter = class {
232
306
  });
233
307
  try {
234
308
  return await new Promise(
235
- (resolve4) => rl.question(question, resolve4)
309
+ (resolve5) => rl.question(question, resolve5)
236
310
  );
237
311
  } finally {
238
312
  rl.close();
@@ -241,8 +315,8 @@ var TtyPrompter = class {
241
315
  async askHidden(question) {
242
316
  let muted = false;
243
317
  const output = new Writable({
244
- write(chunk, _enc, done) {
245
- if (!muted) process.stdout.write(chunk);
318
+ write(chunk2, _enc, done) {
319
+ if (!muted) process.stdout.write(chunk2);
246
320
  done();
247
321
  }
248
322
  });
@@ -253,7 +327,7 @@ var TtyPrompter = class {
253
327
  });
254
328
  try {
255
329
  const answer = new Promise(
256
- (resolve4) => rl.question(question, resolve4)
330
+ (resolve5) => rl.question(question, resolve5)
257
331
  );
258
332
  muted = true;
259
333
  const value = await answer;
@@ -364,8 +438,8 @@ function createContext(flags, positionals = []) {
364
438
  }
365
439
 
366
440
  // src/router.ts
367
- import { readFileSync as readFileSync3 } from "fs";
368
- import { dirname as dirname3, join as join4 } from "path";
441
+ import { readFileSync as readFileSync4 } from "fs";
442
+ import { dirname as dirname3, join as join5 } from "path";
369
443
  import { fileURLToPath } from "url";
370
444
 
371
445
  // src/commands/auth.ts
@@ -462,139 +536,10 @@ async function confirmOrAbort(context, question) {
462
536
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
463
537
  }
464
538
 
465
- // src/commands/cancel.ts
466
- async function runCancel(context) {
467
- const [deploymentId] = context.positionalOrThrow(
468
- 1,
469
- "Usage: naro cancel <deployment-id>"
470
- );
471
- const client = await context.client();
472
- const projectId = await context.resolveProjectId();
473
- await confirmOrAbort(context, `Cancel the build ${String(deploymentId)}?`);
474
- const canceled = await client.deployments.cancel(
475
- projectId,
476
- deploymentId
477
- );
478
- context.output.result(
479
- {
480
- deploymentId: canceled.id,
481
- projectId,
482
- status: canceled.status,
483
- errorCode: canceled.errorCode
484
- },
485
- [`\u2705 Canceled ${canceled.id}`]
486
- );
487
- }
488
-
489
- // src/commands/db.ts
490
- import { readFileSync } from "fs";
491
- import { resolve } from "path";
492
- var processStdin = {
493
- stdinIsTty: () => process.stdin.isTTY === true,
494
- readStdin
495
- };
496
- var USAGE = "Usage: naro db query <sql> | --file <path> | (stdin)";
497
- var ROW_CAP_NOTICE = "\u26A0 Output was cut at 10,000 rows. Add a LIMIT to see the rest.";
498
- function humanSize(bytes) {
499
- const units = ["B", "KB", "MB", "GB"];
500
- let value = bytes;
501
- let unit = 0;
502
- while (value >= 1024 && unit < units.length - 1) {
503
- value /= 1024;
504
- unit += 1;
505
- }
506
- return `${unit === 0 ? String(value) : value.toFixed(1)} ${units[unit] ?? "B"}`;
507
- }
508
- function describeLines(info) {
509
- const lines = [
510
- `Provider: ${info.provider}`,
511
- `Status: ${info.status}`,
512
- `Binding: env.${info.binding}`
513
- ];
514
- if (info.resourceName)
515
- lines.push(
516
- `Database: ${info.resourceName}${info.resourceId ? ` (${info.resourceId})` : ""}`
517
- );
518
- if (info.cloudflare) {
519
- const size = info.cloudflare.fileSize === null ? "?" : humanSize(info.cloudflare.fileSize);
520
- const tables = info.cloudflare.numTables === null ? "?" : String(info.cloudflare.numTables);
521
- lines.push(`Size: ${size} \xB7 Tables: ${tables}`);
522
- }
523
- const { limits } = info;
524
- lines.push(
525
- `Limits: ${humanSize(limits.maxSizeBytes)} per database \xB7 ${humanSize(limits.maxStatementBytes)} per statement \xB7 ${String(limits.maxParams)} params \xB7 ${String(limits.maxStatements)} statements per request \xB7 ${String(limits.maxQuerySeconds)} s per query`
526
- );
527
- if (info.provider === "none")
528
- lines.push("Run `naro db enable` to create the database.");
529
- return lines;
530
- }
531
- async function runDbInfo(context) {
532
- const client = await context.client();
533
- const projectId = await context.resolveProjectId();
534
- const database = await client.db.info(projectId);
535
- context.output.result({ projectId, database }, describeLines(database));
536
- }
537
- async function runDbEnable(context) {
538
- const client = await context.client();
539
- const projectId = await context.resolveProjectId();
540
- const database = await client.db.enable(projectId);
541
- context.output.result({ projectId, database }, [
542
- `\u2705 Database enabled \u2014 ${database.resourceName ?? "(pending)"} (${database.status})`,
543
- ...describeLines(database)
544
- ]);
545
- }
546
- async function readSql(context, deps) {
547
- const positional = context.positionals[0];
548
- let sql;
549
- if (positional !== void 0) {
550
- sql = positional;
551
- } else if (context.flags.file !== void 0) {
552
- try {
553
- sql = readFileSync(resolve(context.cwd, context.flags.file), "utf8");
554
- } catch {
555
- throw new NaroError(
556
- "VALIDATION_FAILED",
557
- `Could not read ${context.flags.file}.`
558
- );
559
- }
560
- } else if (!deps.stdinIsTty()) {
561
- sql = await deps.readStdin();
562
- }
563
- if (sql === void 0 || sql.trim().length === 0)
564
- throw new NaroError("VALIDATION_FAILED", USAGE);
565
- return sql;
566
- }
567
- async function runDbQuery(context, deps = processStdin) {
568
- const sql = await readSql(context, deps);
569
- const client = await context.client();
570
- const projectId = await context.resolveProjectId();
571
- const result = await client.db.query(projectId, { sql });
572
- const lines = [];
573
- result.statements.forEach((statement, index) => {
574
- if (index > 0) lines.push("");
575
- lines.push(...renderRows(statement), summarizeStatement(statement));
576
- });
577
- if (result.truncated) lines.push(ROW_CAP_NOTICE);
578
- context.output.result({ projectId, result }, lines);
579
- }
580
-
581
- // src/commands/db-backup.ts
582
- import {
583
- createWriteStream,
584
- mkdirSync as mkdirSync2,
585
- renameSync,
586
- rmSync,
587
- statSync,
588
- writeFileSync as writeFileSync2
589
- } from "fs";
590
- import { basename, dirname, join as join2, resolve as resolve2 } from "path";
591
- import { Transform } from "stream";
592
- import { pipeline } from "stream/promises";
593
-
594
539
  // src/commands/migration.ts
595
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
540
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
596
541
  import { join, relative } from "path";
597
- var processStdin2 = {
542
+ var processStdin = {
598
543
  stdinIsTty: () => process.stdin.isTTY === true,
599
544
  readStdin
600
545
  };
@@ -633,7 +578,7 @@ function warningLine(warning) {
633
578
  return " \u26A0 recorded without .sql in the ledger";
634
579
  return null;
635
580
  }
636
- async function runMigrationNew(context, deps = processStdin2) {
581
+ async function runMigrationNew(context, deps = processStdin) {
637
582
  const [name] = context.positionalOrThrow(
638
583
  1,
639
584
  "Usage: naro migration new <name>"
@@ -674,6 +619,82 @@ async function runMigrationList(context) {
674
619
  ];
675
620
  context.output.result({ projectId, dir: relDir, migrations }, lines);
676
621
  }
622
+ async function applyMigrationFiles(context, client, projectId, dir, names) {
623
+ const applied = [];
624
+ for (const name of names) {
625
+ const sql = readFileSync(join(dir, name), "utf8");
626
+ try {
627
+ await client.db.applyMigration(projectId, { name, sql });
628
+ } catch (error) {
629
+ if (!isNaroError(error)) throw error;
630
+ const shown = context.output.json ? name : printable(name);
631
+ throw new NaroError(error.code, `${shown}: ${error.message}`, {
632
+ ...error.status === void 0 ? {} : { status: error.status },
633
+ details: { ...error.details, migration: name, applied },
634
+ cause: error
635
+ });
636
+ }
637
+ context.output.log(`\u2713 ${printable(name)}`);
638
+ applied.push(name);
639
+ }
640
+ return applied;
641
+ }
642
+ var MIGRATION_NAME_RE = /^[A-Za-z0-9_.-]{1,200}\.sql$/;
643
+ var LEDGER_DDL = "CREATE TABLE IF NOT EXISTS d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)";
644
+ var LEDGER_INSERT = "INSERT INTO d1_migrations (name) SELECT ? WHERE NOT EXISTS (SELECT 1 FROM d1_migrations WHERE name = ?)";
645
+ var LEDGER_DELETE = "DELETE FROM d1_migrations WHERE name IN (?, ?)";
646
+ function withoutSuffix(key) {
647
+ return key.slice(0, -".sql".length);
648
+ }
649
+ async function runMigrationRepair(context) {
650
+ const [name] = context.positionalOrThrow(
651
+ 1,
652
+ "Usage: naro migration repair <name> --status applied|reverted"
653
+ );
654
+ const key = name;
655
+ if (!MIGRATION_NAME_RE.test(key))
656
+ throw new NaroError(
657
+ "VALIDATION_FAILED",
658
+ "name must end with .sql and contain only letters, digits, _ . -"
659
+ );
660
+ const status = context.flags.status;
661
+ if (status !== "applied" && status !== "reverted")
662
+ throw new NaroError(
663
+ "VALIDATION_FAILED",
664
+ "Pass --status applied or --status reverted."
665
+ );
666
+ const client = await context.client();
667
+ const projectId = await context.resolveProjectId();
668
+ const ledger = await appliedMigrations(client, projectId);
669
+ const recorded = ledger.some(
670
+ (row) => row.name === key || row.name === withoutSuffix(key)
671
+ );
672
+ const nothingToDo = status === "applied" ? `${key} was already applied; nothing to do.` : `${key} was not in the ledger; nothing to do.`;
673
+ if (status === "applied" ? recorded : !recorded) {
674
+ context.output.warn(nothingToDo);
675
+ context.output.result({ name: key, status, changed: false });
676
+ return;
677
+ }
678
+ await confirmOrAbort(
679
+ context,
680
+ status === "applied" ? `Mark ${key} as applied in the project database's ledger? Its SQL is not run.` : `Remove ${key} from the project database's ledger? Its SQL is not rolled back.`
681
+ );
682
+ if (status === "applied" && ledger.length === 0)
683
+ await client.db.query(projectId, { sql: LEDGER_DDL });
684
+ const answer = await client.db.query(projectId, {
685
+ sql: status === "applied" ? LEDGER_INSERT : LEDGER_DELETE,
686
+ params: status === "applied" ? [key, key] : [key, withoutSuffix(key)]
687
+ });
688
+ const changed = (answer.statements[0]?.meta.changes ?? 0) > 0;
689
+ if (!changed) {
690
+ context.output.warn(nothingToDo);
691
+ context.output.result({ name: key, status, changed: false });
692
+ return;
693
+ }
694
+ context.output.result({ name: key, status, changed: true }, [
695
+ status === "applied" ? `\u2713 Marked ${key} as applied.` : `\u2713 Removed ${key} from the ledger.`
696
+ ]);
697
+ }
677
698
  async function runMigrationUp(context) {
678
699
  const client = await context.client();
679
700
  const projectId = await context.resolveProjectId();
@@ -700,92 +721,792 @@ async function runMigrationUp(context) {
700
721
  context,
701
722
  `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
702
723
  );
703
- const applied = [];
704
- for (const name of pending) {
705
- const sql = readFileSync2(join(dir, name), "utf8");
706
- try {
707
- await client.db.applyMigration(projectId, { name, sql });
708
- } catch (error) {
709
- if (!isNaroError(error)) throw error;
710
- const shown = context.output.json ? name : printable(name);
711
- throw new NaroError(error.code, `${shown}: ${error.message}`, {
712
- ...error.status === void 0 ? {} : { status: error.status },
713
- details: { ...error.details, migration: name, applied },
714
- cause: error
715
- });
716
- }
717
- context.output.log(`\u2713 ${printable(name)}`);
718
- applied.push(name);
719
- }
724
+ const applied = await applyMigrationFiles(
725
+ context,
726
+ client,
727
+ projectId,
728
+ dir,
729
+ pending
730
+ );
720
731
  context.output.result({ projectId, applied, pending: [] }, [
721
732
  `\u2705 Applied ${String(applied.length)} migration(s).`
722
733
  ]);
723
734
  }
724
735
 
725
- // src/commands/db-backup.ts
726
- var defaults = {
727
- fetch: (input, init) => fetch(input, init),
728
- stdout: process.stdout,
729
- sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
730
- writeFile: (path, data) => {
731
- mkdirSync2(dirname(path), { recursive: true });
732
- writeFileSync2(path, data, { flag: "wx" });
733
- }
734
- };
735
- var EXPORT_NOTICE = "Exporting the project database. Cloudflare holds other queries to this database until the export finishes.";
736
- function reasonOf(error) {
737
- return error instanceof Error ? error.message : String(error);
736
+ // src/commands/backend.ts
737
+ var SERVICE_KEY_WARNING = "\u26A0 The service key skips every policy. Keep it on a server (NARO_SERVICE_KEY) and never put it in browser code.";
738
+ var ROTATE_USAGE = "Usage: naro backend keys rotate --anon | --service";
739
+ var DISABLE_QUESTION = "Turn the backend off? In up to about 90 seconds your app's sign-ins and data requests get 404 from /_naro/*, until it is turned back on. The data, users, sign-in settings and keys are kept.";
740
+ var OFF_ADVICE = "Turned off: your app's /_naro/* requests get 404. The data, users and keys are kept \u2014 `naro backend enable` turns it back on with the same keys.";
741
+ var TURNING_OFF_ADVICE = "Turning it off did not finish: your app's /_naro/* requests may still be answered. Run `naro backend disable` again to finish it \u2014 the control plane also retries every few minutes.";
742
+ var TURNING_OFF_NOW_ADVICE = "Turning off: another run is removing the routes right now. Check again in a minute with `naro backend`.";
743
+ function leaseHeld(info, now) {
744
+ if (info.leaseExpiresAt === null) return false;
745
+ const until = Date.parse(info.leaseExpiresAt);
746
+ return !Number.isNaN(until) && until > now;
738
747
  }
739
- function withoutUrl(text, url) {
740
- return text.split(url).join("the signed URL").replace(/\S*X-Amz-Signature=\S*/gi, "the signed URL");
748
+ function sentence(text) {
749
+ const line = printable(text).trim();
750
+ return /[.!?…]$/.test(line) ? line : `${line}.`;
741
751
  }
742
- function downloadFailed(url, reason, original) {
743
- const scrubbed = withoutUrl(reason, url);
744
- let cause;
745
- if (original !== void 0) {
746
- cause = new Error(scrubbed);
747
- if (original instanceof Error) {
748
- cause.name = original.name;
749
- if (original.stack !== void 0)
750
- cause.stack = withoutUrl(original.stack, url);
752
+ function statusText(info, now) {
753
+ switch (info.status) {
754
+ case "none":
755
+ return "not provisioned";
756
+ case "ready":
757
+ return "ready";
758
+ default: {
759
+ const text = info.step ? `${info.status} (step: ${info.step})` : info.status;
760
+ return isStalledBackend(info, now) ? `${text}, stalled` : text;
751
761
  }
752
762
  }
753
- return new NaroError(
754
- "DATABASE_UNAVAILABLE",
755
- `Could not download the dump (${scrubbed}). Run naro db dump again.`,
756
- cause === void 0 ? {} : { cause }
757
- );
758
763
  }
759
- function writeFailed(shown, error) {
760
- return new NaroError(
761
- "INTERNAL_ERROR",
762
- `Could not write ${shown}: ${reasonOf(error)}.`,
763
- { cause: error }
764
- );
764
+ function switchText(info) {
765
+ if (info.enabled) return "on";
766
+ if (info.turningOff)
767
+ return info.disabledAt ? `turning off (since ${printable(info.disabledAt)})` : "turning off";
768
+ return info.disabledAt ? `off since ${printable(info.disabledAt)}` : "off";
765
769
  }
766
- var DownloadStreamError = class extends Error {
767
- constructor(original) {
768
- super(reasonOf(original));
769
- this.original = original;
770
- this.name = "DownloadStreamError";
771
- }
772
- };
773
- async function openDownload(deps, url) {
774
- let response;
775
- try {
776
- response = await deps.fetch(url);
777
- } catch (error) {
778
- throw downloadFailed(url, reasonOf(error), error);
770
+ function backendLines(info, now = Date.now()) {
771
+ const lines = [
772
+ `Backend: ${switchText(info)}`,
773
+ `Status: ${statusText(info, now)}`,
774
+ `URL: ${info.url}`
775
+ ];
776
+ if (info.anonKey) lines.push(`Anon key: ${info.anonKey}`);
777
+ if (info.serviceKeyPrefix)
778
+ lines.push(
779
+ `Service key: ${info.serviceKeyPrefix}\u2026 (secret \u2014 \`naro backend keys --reveal\` prints it)`
780
+ );
781
+ return lines;
782
+ }
783
+ function adviceLines(info, now) {
784
+ if (!info.enabled && info.status !== "none") {
785
+ if (!info.turningOff) return [OFF_ADVICE];
786
+ return [leaseHeld(info, now) ? TURNING_OFF_NOW_ADVICE : TURNING_OFF_ADVICE];
779
787
  }
780
- if (!response.ok || response.body === null) {
781
- await response.body?.cancel().catch(() => {
782
- });
783
- throw downloadFailed(url, `HTTP ${String(response.status)}`);
788
+ switch (info.status) {
789
+ case "none":
790
+ return ["Run `naro backend enable` to turn it on."];
791
+ case "error":
792
+ return [
793
+ ...info.error ? [`Error: ${printable(info.error)}`] : [],
794
+ "Run `naro backend enable` again \u2014 it picks up where it stopped."
795
+ ];
796
+ case "provisioning":
797
+ return isStalledBackend(info, now) ? [
798
+ "No run is working on it: the last one stopped part-way. Run `naro backend enable` to pick it up."
799
+ ] : [
800
+ "Provisioning is running. Check again in a minute with `naro backend`."
801
+ ];
802
+ default:
803
+ return [];
784
804
  }
785
- return response.body;
786
805
  }
787
- async function* chunksOf(body) {
788
- const reader = body.getReader();
806
+ function stoppedAt(info) {
807
+ const step = info.step ? `the ${info.step} step` : "a step";
808
+ return `stopped at ${step}${info.error ? `: ${sentence(info.error)}` : "."}`;
809
+ }
810
+ async function runBackendInfo(context) {
811
+ const client = await context.client();
812
+ const projectId = await context.resolveProjectId();
813
+ const backend = await client.backend.info(projectId);
814
+ const now = Date.now();
815
+ context.output.result(
816
+ { projectId, backend, stalled: isStalledBackend(backend, now) },
817
+ [...backendLines(backend, now), ...adviceLines(backend, now)]
818
+ );
819
+ }
820
+ async function runBackendEnable(context) {
821
+ const client = await context.client();
822
+ const projectId = await context.resolveProjectId();
823
+ let backend;
824
+ try {
825
+ backend = await client.backend.enable(projectId);
826
+ } catch (error) {
827
+ if (isNaroError(error) && error.code === "TIMEOUT")
828
+ throw new NaroError(
829
+ "TIMEOUT",
830
+ "Provisioning did not answer within 2 minutes. `naro backend` shows where it got to, and `naro backend enable` again picks up where it stopped.",
831
+ { cause: error }
832
+ );
833
+ throw error;
834
+ }
835
+ if (!backend.enabled) {
836
+ for (const line of backendLines(backend)) context.output.log(line);
837
+ throw new NaroError(
838
+ "BACKEND_BUSY",
839
+ "Another run holds this backend right now, so it is still off. Run `naro backend enable` again in a minute.",
840
+ { details: { projectId, backend } }
841
+ );
842
+ }
843
+ if (backend.status === "error") {
844
+ for (const line of backendLines(backend)) context.output.log(line);
845
+ throw new NaroError(
846
+ "BACKEND_PROVISION_FAILED",
847
+ `Provisioning ${stoppedAt(backend)} Run \`naro backend enable\` again \u2014 it picks up where it stopped.`,
848
+ { details: { projectId, backend } }
849
+ );
850
+ }
851
+ if (backend.status === "provisioning") {
852
+ context.output.result({ projectId, backend }, [
853
+ `\u23F3 Another run is provisioning this backend right now${backend.step ? ` (step: ${backend.step})` : ""}. Check again in a minute with \`naro backend\`.`,
854
+ ...backendLines(backend)
855
+ ]);
856
+ return;
857
+ }
858
+ context.output.result({ projectId, backend }, [
859
+ `\u2705 Backend ready \u2014 ${backend.url}`,
860
+ ...backendLines(backend)
861
+ ]);
862
+ }
863
+ async function runBackendDisable(context) {
864
+ await confirmOrAbort(context, DISABLE_QUESTION);
865
+ const client = await context.client();
866
+ const projectId = await context.resolveProjectId();
867
+ const backend = await client.backend.disable(projectId);
868
+ if (backend.status === "none") {
869
+ context.output.result({ projectId, backend }, [
870
+ "This project has no backend, so there is nothing to turn off.",
871
+ ...backendLines(backend)
872
+ ]);
873
+ return;
874
+ }
875
+ context.output.result({ projectId, backend }, [
876
+ "\u2705 Backend turned off. In up to about 90 seconds your app's /_naro/* requests get 404.",
877
+ "The data, users, sign-in settings and keys are kept \u2014 `naro backend enable` turns it back on with the same keys.",
878
+ ...backendLines(backend)
879
+ ]);
880
+ }
881
+ async function runBackendKeys(context) {
882
+ const client = await context.client();
883
+ const projectId = await context.resolveProjectId();
884
+ const keys = await client.backend.keys(projectId);
885
+ if (!context.flags.reveal) {
886
+ context.output.result(
887
+ {
888
+ projectId,
889
+ anonKey: keys.anonKey,
890
+ serviceKeyPrefix: keys.serviceKeyPrefix
891
+ },
892
+ [
893
+ `Anon key: ${keys.anonKey}`,
894
+ `Service key: ${keys.serviceKeyPrefix}\u2026 (hidden)`,
895
+ "`naro backend keys --reveal` prints the service key (needs database write access)."
896
+ ]
897
+ );
898
+ return;
899
+ }
900
+ const serviceKey = await client.backend.revealServiceKey(projectId);
901
+ context.output.warn(SERVICE_KEY_WARNING);
902
+ context.output.result({ projectId, anonKey: keys.anonKey, serviceKey }, [
903
+ `Anon key: ${keys.anonKey}`,
904
+ `Service key: ${serviceKey}`
905
+ ]);
906
+ }
907
+ function rotateKind(context) {
908
+ const { anon, service } = context.flags;
909
+ if (anon === service) throw new NaroError("VALIDATION_FAILED", ROTATE_USAGE);
910
+ return anon ? "anon" : "service";
911
+ }
912
+ function keyLabel(kind) {
913
+ return kind === "anon" ? "Anon key: " : "Service key: ";
914
+ }
915
+ function rotationWarning(kind, backend) {
916
+ if (backend.status === "error")
917
+ return `\u26A0 The ${kind} key is replaced, but provisioning ${stoppedAt(backend)} Run \`naro backend enable\` to finish.`;
918
+ if (backend.status !== "ready")
919
+ return `\u26A0 The ${kind} key is replaced; another provisioning run is finishing the backend. Check it with \`naro backend\`.`;
920
+ return null;
921
+ }
922
+ function checkKeysSentence(kind) {
923
+ const keys = kind === "service" ? "naro backend keys --reveal" : "naro backend keys";
924
+ return `Run \`${keys}\` to see which key is live, then \`naro backend enable\`.`;
925
+ }
926
+ function rotationHint(kind) {
927
+ return `The rotation may have gone through anyway. ${checkKeysSentence(kind)}`;
928
+ }
929
+ function outcomeUnknown(error) {
930
+ return error.code === "BACKEND_ROTATION_FAILED" || error.code === "INTERNAL_ERROR" || error.code === "NETWORK_ERROR" || error.status !== void 0 && error.status >= 500;
931
+ }
932
+ async function runBackendKeysRotate(context) {
933
+ const kind = rotateKind(context);
934
+ await confirmOrAbort(
935
+ context,
936
+ `Replace the ${kind} key? Every client still sending the old one gets 401 INVALID_API_KEY as soon as the new key is live.`
937
+ );
938
+ const client = await context.client();
939
+ const projectId = await context.resolveProjectId();
940
+ let rotated;
941
+ try {
942
+ rotated = await client.backend.rotateKey(projectId, kind);
943
+ } catch (error) {
944
+ if (!isNaroError(error)) throw error;
945
+ const hint = rotationHint(kind);
946
+ if (error.code === "TIMEOUT")
947
+ throw new NaroError(
948
+ "TIMEOUT",
949
+ `Rotating the key did not answer within 2 minutes, so whether it happened is not known. ${checkKeysSentence(kind)}`,
950
+ { details: { hint }, cause: error }
951
+ );
952
+ if (!outcomeUnknown(error)) throw error;
953
+ context.output.warn(`\u26A0 ${hint}`);
954
+ throw new NaroError(error.code, error.message, {
955
+ ...error.status !== void 0 ? { status: error.status } : {},
956
+ details: { ...error.details, hint },
957
+ cause: error
958
+ });
959
+ }
960
+ const { backend, key } = rotated;
961
+ const warning = rotationWarning(kind, backend);
962
+ if (kind === "service") context.output.warn(SERVICE_KEY_WARNING);
963
+ if (warning) context.output.warn(warning);
964
+ context.output.result(
965
+ { projectId, kind, key, backend, ...warning ? { warning } : {} },
966
+ [
967
+ `\u2705 Replaced the ${kind} key. The old one no longer works.`,
968
+ `${keyLabel(kind)}${key}`,
969
+ kind === "anon" ? "Put the new key in your app and redeploy." : "Update NARO_SERVICE_KEY wherever your servers read it."
970
+ ]
971
+ );
972
+ }
973
+
974
+ // src/commands/backend-auth.ts
975
+ var processStdin2 = {
976
+ stdinIsTty: () => process.stdin.isTTY === true,
977
+ readStdin
978
+ };
979
+ var LABELS = {
980
+ google: "Google",
981
+ github: "GitHub"
982
+ };
983
+ var AUTH_USAGE = {
984
+ email: 'Usage: naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin',
985
+ oauth: "Usage: naro backend auth oauth set google|github --client-id <id> --client-secret-stdin",
986
+ oauthRemove: "Usage: naro backend auth oauth remove google|github",
987
+ redirect: "Usage: naro backend auth redirects add|remove <url>",
988
+ verification: "Usage: naro backend auth require-verification on|off"
989
+ };
990
+ function sentence2(text) {
991
+ const line = printable(text).trim();
992
+ return /[.!?…]$/.test(line) ? line : `${line}.`;
993
+ }
994
+ function authLines(auth) {
995
+ const lines = [
996
+ auth.email ? `Email: ${auth.email.provider} \u2014 ${printable(auth.email.from)} (API key ${printable(auth.email.apiKeyPrefix)}\u2026)` : "Email: not set up"
997
+ ];
998
+ for (const provider of ["google", "github"]) {
999
+ const client = auth.oauth[provider];
1000
+ lines.push(
1001
+ `${`${LABELS[provider]}:`.padEnd(13)}${client ? `${printable(client.clientId)} (secret set)` : "not set up"}`
1002
+ );
1003
+ }
1004
+ lines.push(
1005
+ `Redirects: ${[`${productionOrigins(auth).join(", ")} (production hosts, always)`, ...auth.redirectOrigins].join(", ")}`,
1006
+ `Email verification: ${auth.requireEmailVerification ? "required to sign in" : "not required"}`
1007
+ );
1008
+ return lines;
1009
+ }
1010
+ function productionOrigins(auth) {
1011
+ return auth.productionOrigins?.length ? auth.productionOrigins : [auth.defaultOrigin];
1012
+ }
1013
+ function callbacksOf(auth, provider) {
1014
+ const urls = auth.callbackUrls[provider];
1015
+ if (typeof urls === "string") return [urls];
1016
+ return Array.isArray(urls) ? urls.filter((url) => typeof url === "string") : [];
1017
+ }
1018
+ function adviceLines2(auth) {
1019
+ const lines = [];
1020
+ if (!auth.email)
1021
+ lines.push(
1022
+ "Set up email (confirmation and password reset links) with:",
1023
+ ` ${AUTH_USAGE.email.replace("Usage: ", "")} < resend-key.txt`
1024
+ );
1025
+ lines.push(
1026
+ "Redirect URIs to register with each provider \u2014 the one for the origin your app passes to createClient({ url }):"
1027
+ );
1028
+ for (const provider of ["google", "github"]) {
1029
+ const label = ` ${LABELS[provider]}: `;
1030
+ callbacksOf(auth, provider).forEach(
1031
+ (url, index) => lines.push(`${index === 0 ? label : " ".repeat(label.length)}${url}`)
1032
+ );
1033
+ }
1034
+ return lines;
1035
+ }
1036
+ async function runBackendAuthStatus(context) {
1037
+ const client = await context.client();
1038
+ const projectId = await context.resolveProjectId();
1039
+ const auth = await client.backend.auth(projectId);
1040
+ context.output.result({ projectId, auth }, [
1041
+ ...authLines(auth),
1042
+ ...adviceLines2(auth)
1043
+ ]);
1044
+ }
1045
+ async function readSecret(context, input, given, flag, what, usage) {
1046
+ if (!given)
1047
+ throw new NaroError(
1048
+ "VALIDATION_FAILED",
1049
+ `Pass the ${what} on stdin with ${flag}. ${usage}`
1050
+ );
1051
+ const value = input.stdinIsTty() ? await context.prompter.askHidden(`? ${what}: `) : await input.readStdin();
1052
+ const secret = value.trim();
1053
+ if (secret === "")
1054
+ throw new NaroError("VALIDATION_FAILED", `No ${what} came in on stdin.`);
1055
+ return secret;
1056
+ }
1057
+ async function change(context, send, done, after) {
1058
+ const projectId = await context.resolveProjectId();
1059
+ let answer;
1060
+ try {
1061
+ answer = await send(projectId);
1062
+ } catch (error) {
1063
+ if (isNaroError(error) && error.code === "TIMEOUT")
1064
+ throw new NaroError(
1065
+ "TIMEOUT",
1066
+ "The change did not answer within 2 minutes. `naro backend auth` shows what is saved, and `naro backend enable` finishes putting it on the Naro worker.",
1067
+ { cause: error }
1068
+ );
1069
+ throw error;
1070
+ }
1071
+ const { auth, backend } = answer;
1072
+ if (backend.status === "error") {
1073
+ for (const line of authLines(auth)) context.output.log(line);
1074
+ const step = backend.step ? `the ${backend.step} step` : "a step";
1075
+ throw new NaroError(
1076
+ "BACKEND_PROVISION_FAILED",
1077
+ `Saved, but updating the Naro worker stopped at ${step}${backend.error ? `: ${sentence2(backend.error)}` : "."} Run \`naro backend enable\` to finish.`,
1078
+ { details: { projectId, auth, backend } }
1079
+ );
1080
+ }
1081
+ if (backend.status !== "ready")
1082
+ context.output.warn(
1083
+ "\u26A0 Saved; another provisioning run is finishing the backend. Check it with `naro backend`."
1084
+ );
1085
+ context.output.result({ projectId, auth, backend }, [
1086
+ ...done,
1087
+ ...after ? after(auth) : [],
1088
+ ...authLines(auth)
1089
+ ]);
1090
+ }
1091
+ async function runBackendAuthEmailSet(context, input = processStdin2) {
1092
+ const { provider, from } = context.flags;
1093
+ if (provider === void 0 || from === void 0)
1094
+ throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.email);
1095
+ if (provider !== "resend")
1096
+ throw new NaroError(
1097
+ "VALIDATION_FAILED",
1098
+ `--provider must be resend. ${AUTH_USAGE.email}`
1099
+ );
1100
+ const apiKey = await readSecret(
1101
+ context,
1102
+ input,
1103
+ context.flags.apiKeyStdin,
1104
+ "--api-key-stdin",
1105
+ "Resend API key",
1106
+ AUTH_USAGE.email
1107
+ );
1108
+ const client = await context.client();
1109
+ await change(
1110
+ context,
1111
+ (projectId) => client.backend.setAuth(projectId, {
1112
+ email: { provider, from, apiKey }
1113
+ }),
1114
+ [
1115
+ `\u2705 Email set up: ${provider}, from ${printable(from)}. Confirmation and reset links go out from it.`
1116
+ ]
1117
+ );
1118
+ }
1119
+ async function runBackendAuthEmailRemove(context) {
1120
+ await confirmOrAbort(
1121
+ context,
1122
+ "Remove email? Confirmation and password reset emails stop at once."
1123
+ );
1124
+ const client = await context.client();
1125
+ await change(
1126
+ context,
1127
+ (projectId) => client.backend.removeAuth(projectId, "email"),
1128
+ ["\u2705 Removed email. Confirmation and password reset emails are off."]
1129
+ );
1130
+ }
1131
+ function providerOf(context, usage) {
1132
+ const [provider] = context.positionals;
1133
+ if (provider !== "google" && provider !== "github")
1134
+ throw new NaroError("VALIDATION_FAILED", usage);
1135
+ return provider;
1136
+ }
1137
+ async function runBackendAuthOAuthSet(context, input = processStdin2) {
1138
+ const provider = providerOf(context, AUTH_USAGE.oauth);
1139
+ const clientId = context.flags.clientId;
1140
+ if (clientId === void 0)
1141
+ throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.oauth);
1142
+ const clientSecret = await readSecret(
1143
+ context,
1144
+ input,
1145
+ context.flags.clientSecretStdin,
1146
+ "--client-secret-stdin",
1147
+ `${LABELS[provider]} client secret`,
1148
+ AUTH_USAGE.oauth
1149
+ );
1150
+ const client = await context.client();
1151
+ const patch = {
1152
+ oauth: { [provider]: { clientId, clientSecret } }
1153
+ };
1154
+ await change(
1155
+ context,
1156
+ (projectId) => client.backend.setAuth(projectId, patch),
1157
+ [
1158
+ `\u2705 ${LABELS[provider]} sign-in set up. Register the redirect URI below with the ${LABELS[provider]} app.`
1159
+ ]
1160
+ );
1161
+ }
1162
+ async function runBackendAuthOAuthRemove(context) {
1163
+ const provider = providerOf(context, AUTH_USAGE.oauthRemove);
1164
+ await confirmOrAbort(
1165
+ context,
1166
+ `Remove ${LABELS[provider]} sign-in? Users who signed in with ${LABELS[provider]} cannot sign in with it until it is set up again.`
1167
+ );
1168
+ const client = await context.client();
1169
+ await change(
1170
+ context,
1171
+ (projectId) => client.backend.removeAuth(projectId, provider),
1172
+ [`\u2705 Removed ${LABELS[provider]} sign-in.`]
1173
+ );
1174
+ }
1175
+ async function runBackendAuthRedirectsList(context) {
1176
+ const client = await context.client();
1177
+ const projectId = await context.resolveProjectId();
1178
+ const auth = await client.backend.auth(projectId);
1179
+ const production = productionOrigins(auth);
1180
+ context.output.result(
1181
+ {
1182
+ projectId,
1183
+ defaultOrigin: auth.defaultOrigin,
1184
+ productionOrigins: production,
1185
+ redirectOrigins: auth.redirectOrigins
1186
+ },
1187
+ [
1188
+ ...production.map(
1189
+ (origin) => `${origin} (a production host: every page, unless an entry below narrows it to a path)`
1190
+ ),
1191
+ ...auth.redirectOrigins,
1192
+ ...auth.redirectOrigins.length === 0 ? [
1193
+ "Only the project's own origin may be sent back to. Add another with `naro backend auth redirects add <url>` \u2014 an origin, or only the pages under a path like https://app.example.com/auth/."
1194
+ ] : []
1195
+ ]
1196
+ );
1197
+ }
1198
+ function originOf(context) {
1199
+ const [origin] = context.positionals;
1200
+ if (origin === void 0)
1201
+ throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.redirect);
1202
+ return origin;
1203
+ }
1204
+ async function runBackendAuthRedirectsAdd(context) {
1205
+ const origin = originOf(context);
1206
+ const client = await context.client();
1207
+ await change(
1208
+ context,
1209
+ (projectId) => client.backend.setAuth(projectId, {
1210
+ redirectOrigins: { add: [origin] }
1211
+ }),
1212
+ [`\u2705 Sign-in may now send the browser back to ${printable(origin)}.`]
1213
+ );
1214
+ }
1215
+ function allowing(list, origin) {
1216
+ return list.filter((entry) => parseRedirectEntry(entry)?.origin === origin);
1217
+ }
1218
+ async function runBackendAuthRedirectsRemove(context) {
1219
+ const given = originOf(context);
1220
+ const client = await context.client();
1221
+ const projectId = await context.resolveProjectId();
1222
+ const parsed = parseRedirectEntry(given);
1223
+ if (parsed) {
1224
+ const entry = formatRedirectEntry(parsed);
1225
+ const shown = printable(entry);
1226
+ const current = await client.backend.auth(projectId);
1227
+ if (!current.redirectOrigins.includes(entry)) {
1228
+ if (entry === parsed.origin && productionOrigins(current).includes(entry))
1229
+ throw new NaroError(
1230
+ "REDIRECT_NOT_FOUND",
1231
+ `${shown} is a production host of this project: it is always allowed, and is not in the redirect list to remove.`
1232
+ );
1233
+ const others = allowing(current.redirectOrigins, parsed.origin);
1234
+ const origin = printable(parsed.origin);
1235
+ throw new NaroError(
1236
+ "REDIRECT_NOT_FOUND",
1237
+ `${shown} is not in the redirect list, so nothing was removed. ${others.length > 0 ? `These entries still allow ${origin}: ${others.map(printable).join(", ")}. Remove them by name.` : `Nothing else allows ${origin}; \`naro backend auth redirects\` shows the list.`}`,
1238
+ { details: { projectId, redirectOrigins: current.redirectOrigins } }
1239
+ );
1240
+ }
1241
+ }
1242
+ await change(
1243
+ context,
1244
+ (id) => client.backend.setAuth(id, {
1245
+ redirectOrigins: { remove: [given] }
1246
+ }),
1247
+ [],
1248
+ (auth) => {
1249
+ const origin = parsed?.origin ?? given;
1250
+ const still = [
1251
+ ...productionOrigins(auth).filter((host) => host === origin),
1252
+ ...allowing(auth.redirectOrigins, origin)
1253
+ ];
1254
+ const removed = printable(parsed ? formatRedirectEntry(parsed) : given);
1255
+ return [
1256
+ still.length > 0 ? `\u2705 Removed ${removed}. ${printable(origin)} is still allowed by: ${still.map(printable).join(", ")}.` : `\u2705 Removed ${removed}. Sign-in no longer sends the browser back to ${printable(origin)}.`
1257
+ ];
1258
+ }
1259
+ );
1260
+ }
1261
+ async function runBackendAuthRequireVerification(context) {
1262
+ const [value] = context.positionals;
1263
+ if (value !== "on" && value !== "off")
1264
+ throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.verification);
1265
+ const on = value === "on";
1266
+ const client = await context.client();
1267
+ await change(
1268
+ context,
1269
+ (projectId) => client.backend.setAuth(projectId, { requireEmailVerification: on }),
1270
+ [
1271
+ on ? "\u2705 Sign-in now waits for a confirmed email. New sign-ups get a confirmation link." : "\u2705 Sign-in no longer waits for a confirmed email."
1272
+ ]
1273
+ );
1274
+ }
1275
+
1276
+ // src/commands/cancel.ts
1277
+ async function runCancel(context) {
1278
+ const [deploymentId] = context.positionalOrThrow(
1279
+ 1,
1280
+ "Usage: naro cancel <deployment-id>"
1281
+ );
1282
+ const client = await context.client();
1283
+ const projectId = await context.resolveProjectId();
1284
+ await confirmOrAbort(context, `Cancel the build ${String(deploymentId)}?`);
1285
+ const canceled = await client.deployments.cancel(
1286
+ projectId,
1287
+ deploymentId
1288
+ );
1289
+ context.output.result(
1290
+ {
1291
+ deploymentId: canceled.id,
1292
+ projectId,
1293
+ status: canceled.status,
1294
+ errorCode: canceled.errorCode
1295
+ },
1296
+ [`\u2705 Canceled ${canceled.id}`]
1297
+ );
1298
+ }
1299
+
1300
+ // src/commands/db.ts
1301
+ import { readFileSync as readFileSync2 } from "fs";
1302
+ import { resolve } from "path";
1303
+ var processStdin3 = {
1304
+ stdinIsTty: () => process.stdin.isTTY === true,
1305
+ readStdin
1306
+ };
1307
+ var USAGE = "Usage: naro db query <sql> | --file <path> | (stdin)";
1308
+ var ROW_CAP_NOTICE = "\u26A0 Output was cut at 10,000 rows. Add a LIMIT to see the rest.";
1309
+ function humanSize(bytes) {
1310
+ const units = ["B", "KB", "MB", "GB"];
1311
+ let value = bytes;
1312
+ let unit = 0;
1313
+ while (value >= 1024 && unit < units.length - 1) {
1314
+ value /= 1024;
1315
+ unit += 1;
1316
+ }
1317
+ return `${unit === 0 ? String(value) : value.toFixed(1)} ${units[unit] ?? "B"}`;
1318
+ }
1319
+ function decimalSize(bytes) {
1320
+ const units = ["B", "KB", "MB", "GB"];
1321
+ let value = bytes;
1322
+ let unit = 0;
1323
+ for (; ; ) {
1324
+ const rounded = Number(value.toFixed(2));
1325
+ if (rounded < 1e3 || unit === units.length - 1)
1326
+ return `${String(rounded)} ${units[unit] ?? "B"}`;
1327
+ value /= 1e3;
1328
+ unit += 1;
1329
+ }
1330
+ }
1331
+ function storageSize(bytes) {
1332
+ if (bytes < 1e9) return decimalSize(bytes);
1333
+ const hundredths = Math.floor(bytes / 1e7);
1334
+ return `${String(Math.floor(hundredths / 100))}.${String(hundredths % 100).padStart(2, "0")} GB`;
1335
+ }
1336
+ var STORAGE_WARNING = "\u26A0 This database is over 8 GB of D1's 10 GB limit, which cannot be raised. Writes fail once it is full: delete data you no longer need, or move large blobs to storage (R2).";
1337
+ var WRITE_WARNING_RATIO = 0.8;
1338
+ function usageLines(info) {
1339
+ const usage = info.usage;
1340
+ if (!usage) return [];
1341
+ const lines = [
1342
+ `Writes today (UTC): ${usage.rowsWritten.toLocaleString("en-US")} / ${usage.writeLimit.toLocaleString("en-US")} rows`
1343
+ ];
1344
+ if (usage.writesBlocked)
1345
+ lines.push(
1346
+ "\u2717 Over today's write limit: writes through naro (db query, db push, the Data API) are refused until 00:00 UTC. Reads still work."
1347
+ );
1348
+ else if (usage.rowsWritten >= usage.writeLimit * WRITE_WARNING_RATIO)
1349
+ lines.push(
1350
+ `\u26A0 ${String(Math.floor(usage.rowsWritten / usage.writeLimit * 100))}% of today's write limit is used. At 100%, writes through naro (db query, db push, the Data API) stop until 00:00 UTC.`
1351
+ );
1352
+ const sizeBytes = info.cloudflare?.fileSize ?? usage.storageBytes;
1353
+ if (sizeBytes !== null && usage.storageLimitBytes !== void 0) {
1354
+ lines.push(
1355
+ `Storage: ${storageSize(sizeBytes)} / ${decimalSize(usage.storageLimitBytes)}`
1356
+ );
1357
+ if (usage.storageWarning === true) lines.push(STORAGE_WARNING);
1358
+ }
1359
+ return lines;
1360
+ }
1361
+ function describeLines(info) {
1362
+ const lines = [
1363
+ `Provider: ${info.provider}`,
1364
+ `Status: ${info.status}`,
1365
+ `Binding: env.${info.binding}`
1366
+ ];
1367
+ if (info.resourceName)
1368
+ lines.push(
1369
+ `Database: ${info.resourceName}${info.resourceId ? ` (${info.resourceId})` : ""}`
1370
+ );
1371
+ if (info.cloudflare) {
1372
+ const size = info.cloudflare.fileSize === null ? "?" : storageSize(info.cloudflare.fileSize);
1373
+ const tables = info.cloudflare.numTables === null ? "?" : String(info.cloudflare.numTables);
1374
+ lines.push(`Size: ${size} \xB7 Tables: ${tables}`);
1375
+ }
1376
+ const { limits } = info;
1377
+ lines.push(
1378
+ `Limits: ${decimalSize(limits.maxSizeBytes)} per database \xB7 ${decimalSize(limits.maxStatementBytes)} per statement \xB7 ${String(limits.maxParams)} params \xB7 ${String(limits.maxStatements)} statements per request \xB7 ${String(limits.maxQuerySeconds)} s per query`
1379
+ );
1380
+ lines.push(...usageLines(info));
1381
+ if (info.provider === "none")
1382
+ lines.push("Run `naro db enable` to create the database.");
1383
+ return lines;
1384
+ }
1385
+ async function runDbInfo(context) {
1386
+ const client = await context.client();
1387
+ const projectId = await context.resolveProjectId();
1388
+ const database = await client.db.info(projectId);
1389
+ context.output.result({ projectId, database }, describeLines(database));
1390
+ }
1391
+ async function runDbEnable(context) {
1392
+ const client = await context.client();
1393
+ const projectId = await context.resolveProjectId();
1394
+ const database = await client.db.enable(projectId);
1395
+ context.output.result({ projectId, database }, [
1396
+ `\u2705 Database enabled \u2014 ${database.resourceName ?? "(pending)"} (${database.status})`,
1397
+ ...describeLines(database)
1398
+ ]);
1399
+ }
1400
+ async function readSql(context, deps) {
1401
+ const positional = context.positionals[0];
1402
+ let sql;
1403
+ if (positional !== void 0) {
1404
+ sql = positional;
1405
+ } else if (context.flags.file !== void 0) {
1406
+ try {
1407
+ sql = readFileSync2(resolve(context.cwd, context.flags.file), "utf8");
1408
+ } catch {
1409
+ throw new NaroError(
1410
+ "VALIDATION_FAILED",
1411
+ `Could not read ${context.flags.file}.`
1412
+ );
1413
+ }
1414
+ } else if (!deps.stdinIsTty()) {
1415
+ sql = await deps.readStdin();
1416
+ }
1417
+ if (sql === void 0 || sql.trim().length === 0)
1418
+ throw new NaroError("VALIDATION_FAILED", USAGE);
1419
+ return sql;
1420
+ }
1421
+ async function runDbQuery(context, deps = processStdin3) {
1422
+ const sql = await readSql(context, deps);
1423
+ const client = await context.client();
1424
+ const projectId = await context.resolveProjectId();
1425
+ const result = await client.db.query(projectId, { sql });
1426
+ const lines = [];
1427
+ result.statements.forEach((statement, index) => {
1428
+ if (index > 0) lines.push("");
1429
+ lines.push(...renderRows(statement), summarizeStatement(statement));
1430
+ });
1431
+ if (result.truncated) lines.push(ROW_CAP_NOTICE);
1432
+ context.output.result({ projectId, result }, lines);
1433
+ }
1434
+
1435
+ // src/commands/db-backup.ts
1436
+ import {
1437
+ createWriteStream,
1438
+ mkdirSync as mkdirSync2,
1439
+ renameSync,
1440
+ rmSync,
1441
+ statSync,
1442
+ writeFileSync as writeFileSync2
1443
+ } from "fs";
1444
+ import { basename, dirname, join as join2, resolve as resolve2 } from "path";
1445
+ import { Transform } from "stream";
1446
+ import { pipeline } from "stream/promises";
1447
+ var defaults = {
1448
+ fetch: (input, init) => fetch(input, init),
1449
+ stdout: process.stdout,
1450
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
1451
+ writeFile: (path, data) => {
1452
+ mkdirSync2(dirname(path), { recursive: true });
1453
+ writeFileSync2(path, data, { flag: "wx" });
1454
+ }
1455
+ };
1456
+ var EXPORT_NOTICE = "Exporting the project database. Cloudflare holds other queries to this database until the export finishes.";
1457
+ function reasonOf(error) {
1458
+ return error instanceof Error ? error.message : String(error);
1459
+ }
1460
+ function withoutUrl(text, url) {
1461
+ return text.split(url).join("the signed URL").replace(/\S*X-Amz-Signature=\S*/gi, "the signed URL");
1462
+ }
1463
+ function downloadFailed(url, reason, original) {
1464
+ const scrubbed = withoutUrl(reason, url);
1465
+ let cause;
1466
+ if (original !== void 0) {
1467
+ cause = new Error(scrubbed);
1468
+ if (original instanceof Error) {
1469
+ cause.name = original.name;
1470
+ if (original.stack !== void 0)
1471
+ cause.stack = withoutUrl(original.stack, url);
1472
+ }
1473
+ }
1474
+ return new NaroError(
1475
+ "DATABASE_UNAVAILABLE",
1476
+ `Could not download the dump (${scrubbed}). Run naro db dump again.`,
1477
+ cause === void 0 ? {} : { cause }
1478
+ );
1479
+ }
1480
+ function writeFailed(shown, error) {
1481
+ return new NaroError(
1482
+ "INTERNAL_ERROR",
1483
+ `Could not write ${shown}: ${reasonOf(error)}.`,
1484
+ { cause: error }
1485
+ );
1486
+ }
1487
+ var DownloadStreamError = class extends Error {
1488
+ constructor(original) {
1489
+ super(reasonOf(original));
1490
+ this.original = original;
1491
+ this.name = "DownloadStreamError";
1492
+ }
1493
+ };
1494
+ async function openDownload(deps, url) {
1495
+ let response;
1496
+ try {
1497
+ response = await deps.fetch(url);
1498
+ } catch (error) {
1499
+ throw downloadFailed(url, reasonOf(error), error);
1500
+ }
1501
+ if (!response.ok || response.body === null) {
1502
+ await response.body?.cancel().catch(() => {
1503
+ });
1504
+ throw downloadFailed(url, `HTTP ${String(response.status)}`);
1505
+ }
1506
+ return response.body;
1507
+ }
1508
+ async function* chunksOf(body) {
1509
+ const reader = body.getReader();
789
1510
  const read = async () => {
790
1511
  try {
791
1512
  return await reader.read();
@@ -807,9 +1528,9 @@ async function* chunksOf(body) {
807
1528
  async function streamInto(body, destination, end) {
808
1529
  let bytes = 0;
809
1530
  const counter = new Transform({
810
- transform(chunk, _encoding, done) {
811
- bytes += chunk.length;
812
- done(null, chunk);
1531
+ transform(chunk2, _encoding, done) {
1532
+ bytes += chunk2.length;
1533
+ done(null, chunk2);
813
1534
  }
814
1535
  });
815
1536
  await pipeline(chunksOf(body), counter, destination, { end });
@@ -914,13 +1635,13 @@ async function runDbDump(context, overrides = {}) {
914
1635
  }
915
1636
  context.output.result({ output: flags.output, bytes, atBookmark: bookmark });
916
1637
  }
917
- async function bookmarkNow(context, client, projectId) {
1638
+ async function bookmarkNow(context, client, projectId, action = "restoring") {
918
1639
  try {
919
1640
  const { bookmark } = await client.db.bookmark(projectId);
920
1641
  return isBookmark(bookmark) ? bookmark : null;
921
1642
  } catch (error) {
922
1643
  context.output.debugLog(
923
- `Could not read the current bookmark before restoring: ${reasonOf(error)}`
1644
+ `Could not read the current bookmark before ${action}: ${reasonOf(error)}`
924
1645
  );
925
1646
  return null;
926
1647
  }
@@ -1009,43 +1730,567 @@ async function runDbPull(context, overrides = {}) {
1009
1730
  const shown = relativeToCwd(context, path);
1010
1731
  const client = await context.client();
1011
1732
  const projectId = await context.resolveProjectId();
1012
- await confirmOrAbort(
1013
- context,
1014
- `Write ${shown} with the database's current schema and record it as already applied?`
1733
+ await confirmOrAbort(
1734
+ context,
1735
+ `Write ${shown} with the database's current schema and record it as already applied?`
1736
+ );
1737
+ const pulled = await client.db.pull(projectId, { name });
1738
+ try {
1739
+ deps.writeFile(path, `${pulled.sql}
1740
+ `);
1741
+ } catch (error) {
1742
+ if (!context.output.json) context.output.log(pulled.sql);
1743
+ throw new NaroError(
1744
+ "INTERNAL_ERROR",
1745
+ `Recorded ${pulled.name} as applied but could not write ${shown}: ${reasonOf(error)}. The SQL is on stdout \u2014 save it as that file.`,
1746
+ {
1747
+ details: { migration: pulled.name, path: shown, sql: pulled.sql },
1748
+ cause: error
1749
+ }
1750
+ );
1751
+ }
1752
+ context.output.result(
1753
+ {
1754
+ projectId,
1755
+ name: pulled.name,
1756
+ path: shown,
1757
+ statements: pulled.statements
1758
+ },
1759
+ [
1760
+ `\u2713 Wrote ${shown} (${String(pulled.statements)} ${pulled.statements === 1 ? "statement" : "statements"}) and recorded it as applied.`
1761
+ ]
1762
+ );
1763
+ }
1764
+
1765
+ // src/commands/db-lint.ts
1766
+ var DIAGNOSTICS_SQL = [
1767
+ `SELECT name FROM sqlite_master WHERE type = 'trigger' AND ${systemTablePredicate("name")} ORDER BY name;`,
1768
+ "SELECT name, type FROM pragma_table_list WHERE schema = 'main' AND type IN ('virtual', 'shadow') ORDER BY name;",
1769
+ "SELECT * FROM pragma_quick_check;",
1770
+ "SELECT * FROM pragma_foreign_key_check;"
1771
+ ].join("\n");
1772
+ var TRUNCATED_NOTICE = "\u26A0 The diagnostics were cut at 10,000 rows \u2014 there may be more broken rows than these.";
1773
+ function firstCell(row) {
1774
+ const value = row[0];
1775
+ return value === null || value === void 0 ? "" : String(value);
1776
+ }
1777
+ function rowidOf(text) {
1778
+ if (text === "") return null;
1779
+ const value = Number(text);
1780
+ return Number.isFinite(value) ? value : null;
1781
+ }
1782
+ function issueLines(issues, tables) {
1783
+ const errors = issues.filter((issue) => issue.severity === "error").length;
1784
+ if (issues.length === 0)
1785
+ return [`\u2713 No issues found in ${String(tables)} table(s).`];
1786
+ return [
1787
+ ...issues.map(
1788
+ (issue) => printable(
1789
+ `${issue.severity.padEnd(5)} ${issue.rule} ${issue.object}: ${issue.message}`
1790
+ )
1791
+ ),
1792
+ `${String(errors)} error(s), ${String(issues.length - errors)} warning(s) in ${String(tables)} table(s).`
1793
+ ];
1794
+ }
1795
+ async function runDbLint(context) {
1796
+ const client = await context.client();
1797
+ const projectId = await context.resolveProjectId();
1798
+ const tables = await client.db.tables(projectId);
1799
+ const answer = await client.db.query(projectId, { sql: DIAGNOSTICS_SQL });
1800
+ const [triggers, objects, quick, broken] = answer.statements;
1801
+ if (triggers === void 0 || objects === void 0 || quick === void 0 || broken === void 0)
1802
+ throw new NaroError(
1803
+ "DATABASE_QUERY_FAILED",
1804
+ `The diagnostics could not be read: the database answered with ${String(answer.statements.length)} result sets instead of 4.`
1805
+ );
1806
+ const virtualTables = [];
1807
+ const shadow = /* @__PURE__ */ new Set();
1808
+ for (const row of objects.rows) {
1809
+ const name = cellText(objects, row, "name");
1810
+ if (cellText(objects, row, "type") === "shadow") shadow.add(name);
1811
+ else virtualTables.push(name);
1812
+ }
1813
+ const foreignKeyCheck = broken.rows.map((row) => ({
1814
+ table: cellText(broken, row, "table"),
1815
+ rowid: rowidOf(cellText(broken, row, "rowid")),
1816
+ parent: cellText(broken, row, "parent")
1817
+ }));
1818
+ const input = {
1819
+ tables: tables.filter((table) => !shadow.has(table.name)),
1820
+ foreignKeyCheck,
1821
+ quickCheck: quick.rows.map(firstCell),
1822
+ triggers: triggers.rows.map((row) => cellText(triggers, row, "name")),
1823
+ virtualTables
1824
+ };
1825
+ const issues = lintSchema(input);
1826
+ const counted = countLintedTables(input);
1827
+ const errors = issues.filter((issue) => issue.severity === "error").length;
1828
+ const warnings = issues.length - errors;
1829
+ if (answer.truncated) context.output.warn(TRUNCATED_NOTICE);
1830
+ context.output.result(
1831
+ { issues, errors, warnings, tables: counted },
1832
+ issueLines(issues, counted)
1833
+ );
1834
+ if (errors > 0 || context.flags.strict === true && warnings > 0)
1835
+ process.exitCode = 1;
1836
+ }
1837
+
1838
+ // src/commands/db-reset.ts
1839
+ var MAX_STATEMENTS = 50;
1840
+ var SCHEMA_FILTER = `${systemTablePredicate("name")} AND ${systemTablePredicate("tbl_name")}`;
1841
+ var SCHEMA_SQL = [
1842
+ `SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view', 'trigger') AND ${SCHEMA_FILTER} ORDER BY name;`,
1843
+ "SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'virtual';",
1844
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations');"
1845
+ ].join("\n");
1846
+ function literal(value) {
1847
+ return `'${value.replace(/'/g, "''")}'`;
1848
+ }
1849
+ function chunk(items2, size) {
1850
+ const pieces = [];
1851
+ for (let index = 0; index < items2.length; index += size)
1852
+ pieces.push(items2.slice(index, index + size));
1853
+ return pieces;
1854
+ }
1855
+ async function readSchema(client, projectId) {
1856
+ const schema = await client.db.query(projectId, { sql: SCHEMA_SQL });
1857
+ const [listed, virtual, internal] = schema.statements;
1858
+ if (listed === void 0 || virtual === void 0 || internal === void 0)
1859
+ throw new NaroError(
1860
+ "DATABASE_QUERY_FAILED",
1861
+ `The schema could not be read: the database answered with ${String(schema.statements.length)} result sets instead of 3.`
1862
+ );
1863
+ const internalNames = new Set(
1864
+ internal.rows.map((row) => cellText(internal, row, "name"))
1865
+ );
1866
+ const virtualNames = new Set(
1867
+ virtual.rows.map((row) => cellText(virtual, row, "name").toLowerCase())
1868
+ );
1869
+ const objects = listed.rows.map((row) => {
1870
+ const name = cellText(listed, row, "name");
1871
+ const type = cellText(listed, row, "type");
1872
+ if (type === "view" || type === "trigger") return { type, name };
1873
+ return {
1874
+ type: virtualNames.has(name.toLowerCase()) ? "virtual" : "table",
1875
+ name
1876
+ };
1877
+ });
1878
+ const lookups = objects.filter((object) => object.type === "table" || object.type === "virtual").map(
1879
+ (object) => `SELECT ${literal(object.name)} AS child, "table" AS parent FROM pragma_foreign_key_list(${literal(object.name)});`
1880
+ );
1881
+ const foreignKeys = [];
1882
+ for (const piece of chunk(lookups, MAX_STATEMENTS)) {
1883
+ const answer = await client.db.query(projectId, { sql: piece.join("\n") });
1884
+ if (answer.truncated)
1885
+ throw new NaroError(
1886
+ "DATABASE_QUERY_FAILED",
1887
+ "The schema is too large to read in one pass: the foreign key lookup was cut at 10,000 rows."
1888
+ );
1889
+ for (const statement of answer.statements)
1890
+ for (const row of statement.rows)
1891
+ foreignKeys.push({
1892
+ child: cellText(statement, row, "child"),
1893
+ parent: cellText(statement, row, "parent")
1894
+ });
1895
+ }
1896
+ return {
1897
+ objects,
1898
+ foreignKeys,
1899
+ internal: {
1900
+ sqliteSequence: internalNames.has("sqlite_sequence"),
1901
+ ledger: internalNames.has("d1_migrations")
1902
+ }
1903
+ };
1904
+ }
1905
+ function resetStopped(context, error, bookmark, lead, message) {
1906
+ const naroError = isNaroError(error) ? error : toNaroError(error);
1907
+ if (!context.output.json)
1908
+ context.output.warn(
1909
+ bookmark === null ? lead : `${lead} The database was at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark} undoes the reset.`
1910
+ );
1911
+ return new NaroError(naroError.code, message(naroError.message), {
1912
+ ...naroError.status === void 0 ? {} : { status: naroError.status },
1913
+ details: {
1914
+ ...naroError.details,
1915
+ ...bookmark === null ? {} : { bookmark }
1916
+ },
1917
+ cause: naroError
1918
+ });
1919
+ }
1920
+ async function runDbReset(context) {
1921
+ const dir = migrationsDir(context);
1922
+ const shownDir = relativeToCwd(context, dir);
1923
+ const files = listMigrationFiles(dir);
1924
+ const client = await context.client();
1925
+ const projectId = await context.resolveProjectId();
1926
+ const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
1927
+ const plan = buildResetPlan(await readSchema(client, projectId));
1928
+ const { tables, views, triggers } = plan.dropped;
1929
+ const counts = `${String(tables.length)} table(s), ${String(views.length)} view(s) and ${String(triggers.length)} trigger(s)`;
1930
+ const dropped = tables.length + views.length + triggers.length;
1931
+ if (context.flags.dryRun) {
1932
+ context.output.result(
1933
+ { dryRun: true, tables, views, triggers, migrations: files },
1934
+ [
1935
+ `Would drop ${counts}:`,
1936
+ ...views.map((name) => ` ${"view".padEnd(9)}${printable(name)}`),
1937
+ ...triggers.map((name) => ` ${"trigger".padEnd(9)}${printable(name)}`),
1938
+ ...tables.map((name) => ` ${"table".padEnd(9)}${printable(name)}`),
1939
+ `Would re-apply ${String(files.length)} migration file(s) from ${shownDir}:`,
1940
+ ...files.map((name) => ` ${printable(name)}`),
1941
+ "Nothing was changed."
1942
+ ]
1943
+ );
1944
+ return;
1945
+ }
1946
+ if (bookmark !== null)
1947
+ context.output.log(
1948
+ `The database is at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark} undoes the reset.`
1949
+ );
1950
+ await confirmOrAbort(
1951
+ context,
1952
+ `Reset the project database? This drops ${counts}, clears the migration ledger, then re-applies ${String(files.length)} migration file(s) from ${shownDir}.`
1953
+ );
1954
+ let done = 0;
1955
+ const total = plan.batches.length;
1956
+ for (const batch of plan.batches) {
1957
+ try {
1958
+ await client.db.query(projectId, { sql: batch.join("\n") });
1959
+ } catch (error) {
1960
+ throw resetStopped(
1961
+ context,
1962
+ error,
1963
+ bookmark,
1964
+ "Part of the schema is already dropped.",
1965
+ (underlying) => `The reset stopped after ${String(done)} of ${String(total)} batches: ${underlying}`
1966
+ );
1967
+ }
1968
+ done += 1;
1969
+ }
1970
+ if (files.length === 0)
1971
+ context.output.warn(
1972
+ `No migration files in ${shownDir} \u2014 the database is now empty.`
1973
+ );
1974
+ let reapplied = [];
1975
+ try {
1976
+ if (files.length > 0)
1977
+ reapplied = await applyMigrationFiles(
1978
+ context,
1979
+ client,
1980
+ projectId,
1981
+ dir,
1982
+ files
1983
+ );
1984
+ } catch (error) {
1985
+ throw resetStopped(
1986
+ context,
1987
+ error,
1988
+ bookmark,
1989
+ "The schema is dropped and the re-apply stopped.",
1990
+ (underlying) => underlying
1991
+ );
1992
+ }
1993
+ context.output.result({ dropped, reapplied: reapplied.length, bookmark }, [
1994
+ `\u2713 Reset the project database: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
1995
+ ...bookmark === null ? [] : [` To undo: naro db restore --bookmark ${bookmark}`]
1996
+ ]);
1997
+ }
1998
+
1999
+ // src/commands/db-schema.ts
2000
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
2001
+ import { join as join3 } from "path";
2002
+ var USAGE2 = {
2003
+ table: "Usage: naro db table create|add-column|rename-column|rename|drop-column|drop \u2026 (see naro help)",
2004
+ index: "Usage: naro db index create|drop \u2026 (see naro help)",
2005
+ create: "Usage: naro db table create <name> --column <spec> [--column <spec>\u2026] [--owner <column>] [--if-not-exists]",
2006
+ addColumn: "Usage: naro db table add-column <table> --column <spec>",
2007
+ renameColumn: "Usage: naro db table rename-column <table> <column> <new-name>",
2008
+ rename: "Usage: naro db table rename <table> <new-name>",
2009
+ dropColumn: "Usage: naro db table drop-column <table> <column>",
2010
+ drop: "Usage: naro db table drop <table>",
2011
+ indexCreate: "Usage: naro db index create <table> <column[,column\u2026]> [--unique] [--name <name>]",
2012
+ indexDrop: "Usage: naro db index drop <name>"
2013
+ };
2014
+ var SPEC_USAGE = '--column needs name:type, e.g. --column "title:text:notnull".';
2015
+ var ON_DELETE = {
2016
+ cascade: "CASCADE",
2017
+ "set-null": "SET NULL",
2018
+ "set-default": "SET DEFAULT",
2019
+ restrict: "RESTRICT",
2020
+ "no-action": "NO ACTION"
2021
+ };
2022
+ var KEYWORDS = /* @__PURE__ */ new Set(["CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME"]);
2023
+ var NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
2024
+ function segments(spec) {
2025
+ const parts = [];
2026
+ let current = "";
2027
+ let quoted = false;
2028
+ for (const char of spec) {
2029
+ if (char === "'") quoted = !quoted;
2030
+ if (char === ":" && !quoted) {
2031
+ parts.push(current);
2032
+ current = "";
2033
+ continue;
2034
+ }
2035
+ current += char;
2036
+ }
2037
+ if (quoted) return null;
2038
+ parts.push(current);
2039
+ return parts;
2040
+ }
2041
+ function defaultValue(text) {
2042
+ if (text.length >= 2 && text.startsWith("'") && text.endsWith("'"))
2043
+ return text.slice(1, -1).replace(/''/g, "'");
2044
+ const lower = text.toLowerCase();
2045
+ if (lower === "null") return null;
2046
+ if (lower === "true") return true;
2047
+ if (lower === "false") return false;
2048
+ const upper = text.toUpperCase();
2049
+ if (KEYWORDS.has(upper)) return { keyword: upper };
2050
+ if (NUMBER.test(text)) return Number(text);
2051
+ return text;
2052
+ }
2053
+ function parseColumnSpec(spec) {
2054
+ const shown = `--column "${printable(spec)}"`;
2055
+ const parts = segments(spec);
2056
+ if (parts === null)
2057
+ throw new NaroError(
2058
+ "VALIDATION_FAILED",
2059
+ `${shown}: a quoted default is not closed.`
2060
+ );
2061
+ const [name, type, ...modifiers] = parts;
2062
+ if (!name || !type) throw new NaroError("VALIDATION_FAILED", SPEC_USAGE);
2063
+ const column = {
2064
+ name,
2065
+ type
2066
+ };
2067
+ let onDelete;
2068
+ for (const modifier of modifiers) {
2069
+ const eq = modifier.indexOf("=");
2070
+ const key = (eq < 0 ? modifier : modifier.slice(0, eq)).toLowerCase();
2071
+ const value = eq < 0 ? void 0 : modifier.slice(eq + 1);
2072
+ if (value === void 0 && key === "pk") column.primaryKey = true;
2073
+ else if (value === void 0 && key === "autoincrement")
2074
+ column.autoIncrement = true;
2075
+ else if (value === void 0 && key === "notnull") column.notNull = true;
2076
+ else if (value === void 0 && key === "unique") column.unique = true;
2077
+ else if (value !== void 0 && key === "default")
2078
+ column.default = defaultValue(value);
2079
+ else if (value !== void 0 && key === "references") {
2080
+ const dot = value.lastIndexOf(".");
2081
+ const table = dot < 0 ? "" : value.slice(0, dot);
2082
+ const target = dot < 0 ? "" : value.slice(dot + 1);
2083
+ if (!table || !target)
2084
+ throw new NaroError(
2085
+ "VALIDATION_FAILED",
2086
+ `${shown}: references needs <table>.<column>.`
2087
+ );
2088
+ column.references = { table, column: target };
2089
+ } else if (value !== void 0 && key === "ondelete") {
2090
+ onDelete = ON_DELETE[value.toLowerCase()];
2091
+ if (!onDelete)
2092
+ throw new NaroError(
2093
+ "VALIDATION_FAILED",
2094
+ `${shown}: ondelete takes cascade, set-null, set-default, restrict or no-action.`
2095
+ );
2096
+ } else
2097
+ throw new NaroError(
2098
+ "VALIDATION_FAILED",
2099
+ `${shown}: unknown modifier ${printable(modifier)}. Use pk, autoincrement, notnull, unique, default=<value>, references=<table>.<column> or ondelete=<action>.`
2100
+ );
2101
+ }
2102
+ if (onDelete !== void 0) {
2103
+ if (!column.references)
2104
+ throw new NaroError(
2105
+ "VALIDATION_FAILED",
2106
+ `${shown}: ondelete needs references=<table>.<column>.`
2107
+ );
2108
+ column.references.onDelete = onDelete;
2109
+ }
2110
+ return column;
2111
+ }
2112
+ function policyLine(policy, dryRun = false) {
2113
+ const rules = [policy.select, policy.insert, policy.update, policy.delete];
2114
+ const owner = policy.ownerColumn === null ? "" : ` (owner column ${printable(policy.ownerColumn)})`;
2115
+ const uniform = rules.every((rule) => rule === rules[0]);
2116
+ const summary = uniform ? `${String(rules[0])} for select, insert, update and delete` : `select ${policy.select}, insert ${policy.insert}, update ${policy.update}, delete ${policy.delete}`;
2117
+ const head = dryRun ? "Would set the Data API policy" : "\u2713 Data API policy";
2118
+ return `${head} on ${printable(policy.table)}: ${summary}${owner}.`;
2119
+ }
2120
+ function removedLine(policy, dryRun) {
2121
+ const owner = policy.ownerColumn === null ? "" : `, owner column ${printable(policy.ownerColumn)}`;
2122
+ const rules = `select ${policy.select}, insert ${policy.insert}, update ${policy.update}, delete ${policy.delete}${owner}`;
2123
+ return `${dryRun ? "Would remove" : "\u2713 Removed"} the Data API policy on ${printable(policy.table)} (${rules}).`;
2124
+ }
2125
+ async function runSchemaChange(context, operations, question) {
2126
+ const dryRun = context.flags.dryRun === true;
2127
+ if (question !== void 0 && !dryRun)
2128
+ await confirmOrAbort(context, question);
2129
+ const sent = dryRun ? operations.map((operation) => {
2130
+ if (operation.op !== "dropTable" && operation.op !== "dropColumn")
2131
+ return operation;
2132
+ const { confirm: _confirm, ...rest } = operation;
2133
+ return rest;
2134
+ }) : operations;
2135
+ const client = await context.client();
2136
+ const projectId = await context.resolveProjectId();
2137
+ const dir = migrationsDir(context);
2138
+ const local = listMigrationFiles(dir);
2139
+ const ledger = await appliedMigrations(client, projectId);
2140
+ const pending = pendingMigrationNames(local, ledger);
2141
+ if (pending.length > 0 && !dryRun)
2142
+ throw migrationsPendingError(pending.map(printable));
2143
+ const name = nextMigrationFileName(
2144
+ [...local, ...ledger.map((row) => row.name)],
2145
+ schemaMigrationTitle(operations[0] ?? {})
1015
2146
  );
1016
- const pulled = await client.db.pull(projectId, { name });
1017
- try {
1018
- deps.writeFile(path, `${pulled.sql}
1019
- `);
1020
- } catch (error) {
1021
- if (!context.output.json) context.output.log(pulled.sql);
1022
- throw new NaroError(
1023
- "INTERNAL_ERROR",
1024
- `Recorded ${pulled.name} as applied but could not write ${shown}: ${reasonOf(error)}. The SQL is on stdout \u2014 save it as that file.`,
1025
- {
1026
- details: { migration: pulled.name, path: shown, sql: pulled.sql },
1027
- cause: error
1028
- }
1029
- );
2147
+ const result = await client.db.schema(projectId, sent, {
2148
+ ...dryRun ? { dryRun: true } : {},
2149
+ name
2150
+ });
2151
+ const sqlLines = result.sql.trimEnd().split("\n").map(printable);
2152
+ const removed = result.removedPolicies ?? [];
2153
+ if (result.dryRun) {
2154
+ for (const warning of result.warnings)
2155
+ context.output.warn(`\u26A0 ${printable(warning)}`);
2156
+ context.output.result({ projectId, ...result, file: null }, [
2157
+ ...sqlLines,
2158
+ `Dry run \u2014 nothing was applied. It would be recorded as ${printable(result.name)}.`,
2159
+ ...result.policies.map((policy) => policyLine(policy, true)),
2160
+ ...removed.map((policy) => removedLine(policy, true))
2161
+ ]);
2162
+ return;
2163
+ }
2164
+ const allWarnings = result.name === name ? result.warnings : [...result.warnings, migrationNameMismatch(name, result.name)];
2165
+ let file = null;
2166
+ let fileWarning;
2167
+ const relDir = relativeToCwd(context, dir);
2168
+ const rel = join3(relDir === "." ? "" : relDir, name);
2169
+ if (context.flags.noWrite) {
2170
+ fileWarning = `Not written to ${relDir} (--no-write): naro db reset will not re-apply ${result.name}, and naro migration list shows it as remote-only.`;
2171
+ } else {
2172
+ try {
2173
+ mkdirSync3(dir, { recursive: true });
2174
+ writeFileSync3(join3(dir, name), result.sql, { flag: "wx" });
2175
+ file = rel;
2176
+ } catch (error) {
2177
+ const reason = (error instanceof Error ? error.message : String(error)).replace(/[.\s]+$/, "");
2178
+ fileWarning = `Applied, but ${rel} could not be written: ${reason}. Save the SQL above as ${rel} so naro db push and naro db reset see it.`;
2179
+ }
1030
2180
  }
2181
+ for (const warning of allWarnings)
2182
+ context.output.warn(`\u26A0 ${printable(warning)}`);
2183
+ if (fileWarning) context.output.warn(`\u26A0 ${printable(fileWarning)}`);
1031
2184
  context.output.result(
1032
2185
  {
1033
2186
  projectId,
1034
- name: pulled.name,
1035
- path: shown,
1036
- statements: pulled.statements
2187
+ ...result,
2188
+ warnings: allWarnings,
2189
+ file,
2190
+ ...fileWarning ? { fileWarning } : {}
1037
2191
  },
1038
2192
  [
1039
- `\u2713 Wrote ${shown} (${String(pulled.statements)} ${pulled.statements === 1 ? "statement" : "statements"}) and recorded it as applied.`
2193
+ ...sqlLines,
2194
+ file === null ? `\u2705 Applied ${printable(result.name)}.` : `\u2705 Applied ${printable(result.name)} and wrote ${printable(file)}.`,
2195
+ ...result.policies.map((policy) => policyLine(policy)),
2196
+ ...removed.map((policy) => removedLine(policy, false))
1040
2197
  ]
1041
2198
  );
1042
2199
  }
2200
+ function columnsFlag(context) {
2201
+ return context.flags.columns ?? [];
2202
+ }
2203
+ async function runDbTableUsage() {
2204
+ throw new NaroError("VALIDATION_FAILED", USAGE2.table);
2205
+ }
2206
+ async function runDbIndexUsage() {
2207
+ throw new NaroError("VALIDATION_FAILED", USAGE2.index);
2208
+ }
2209
+ async function runDbTableCreate(context) {
2210
+ const [table] = context.positionals;
2211
+ const specs = columnsFlag(context);
2212
+ if (!table || specs.length === 0)
2213
+ throw new NaroError("VALIDATION_FAILED", USAGE2.create);
2214
+ const columns = specs.map(parseColumnSpec);
2215
+ const owner = context.flags.ownerColumn;
2216
+ await runSchemaChange(context, [
2217
+ {
2218
+ op: "createTable",
2219
+ table,
2220
+ columns,
2221
+ ...context.flags.ifNotExists ? { ifNotExists: true } : {},
2222
+ ...owner === void 0 ? {} : { owner: { column: owner } }
2223
+ }
2224
+ ]);
2225
+ }
2226
+ async function runDbTableAddColumn(context) {
2227
+ const [table] = context.positionals;
2228
+ const specs = columnsFlag(context);
2229
+ const [spec] = specs;
2230
+ if (!table || spec === void 0 || specs.length !== 1)
2231
+ throw new NaroError("VALIDATION_FAILED", USAGE2.addColumn);
2232
+ await runSchemaChange(context, [
2233
+ { op: "addColumn", table, column: parseColumnSpec(spec) }
2234
+ ]);
2235
+ }
2236
+ async function runDbTableRenameColumn(context) {
2237
+ const [table, column, to] = context.positionals;
2238
+ if (!table || !column || !to)
2239
+ throw new NaroError("VALIDATION_FAILED", USAGE2.renameColumn);
2240
+ await runSchemaChange(context, [{ op: "renameColumn", table, column, to }]);
2241
+ }
2242
+ async function runDbTableRename(context) {
2243
+ const [table, to] = context.positionals;
2244
+ if (!table || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
2245
+ await runSchemaChange(context, [{ op: "renameTable", table, to }]);
2246
+ }
2247
+ async function runDbTableDropColumn(context) {
2248
+ const [table, column] = context.positionals;
2249
+ if (!table || !column)
2250
+ throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
2251
+ const target = `${table}.${column}`;
2252
+ await runSchemaChange(
2253
+ context,
2254
+ [{ op: "dropColumn", table, column, confirm: target }],
2255
+ `Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
2256
+ );
2257
+ }
2258
+ async function runDbTableDrop(context) {
2259
+ const [table] = context.positionals;
2260
+ if (!table) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
2261
+ await runSchemaChange(
2262
+ context,
2263
+ [{ op: "dropTable", table, confirm: table }],
2264
+ `Drop table ${printable(table)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
2265
+ );
2266
+ }
2267
+ async function runDbIndexCreate(context) {
2268
+ const [table, list] = context.positionals;
2269
+ const columns = listFlag(list);
2270
+ if (!table || columns.length === 0)
2271
+ throw new NaroError("VALIDATION_FAILED", USAGE2.indexCreate);
2272
+ const name = context.flags.name;
2273
+ await runSchemaChange(context, [
2274
+ {
2275
+ op: "createIndex",
2276
+ table,
2277
+ columns,
2278
+ ...context.flags.unique ? { unique: true } : {},
2279
+ ...name === void 0 ? {} : { name }
2280
+ }
2281
+ ]);
2282
+ }
2283
+ async function runDbIndexDrop(context) {
2284
+ const [name] = context.positionals;
2285
+ if (!name) throw new NaroError("VALIDATION_FAILED", USAGE2.indexDrop);
2286
+ await runSchemaChange(context, [{ op: "dropIndex", name }]);
2287
+ }
1043
2288
 
1044
2289
  // src/commands/git.ts
1045
2290
  var defaults2 = {
1046
2291
  detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
1047
2292
  openBrowser,
1048
- sleep: (ms) => new Promise((resolve4) => setTimeout(resolve4, ms))
2293
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
1049
2294
  };
1050
2295
  function connected(context, project) {
1051
2296
  const branch = project.defaultBranch;
@@ -1115,7 +2360,7 @@ async function waitForInstall(context, projectId, fullName, deps) {
1115
2360
  if (teamSlug) {
1116
2361
  const attempts = deps.pollAttempts ?? 60;
1117
2362
  const interval = deps.pollIntervalMs ?? 3e3;
1118
- for (let attempt = 0; attempt < attempts; attempt += 1) {
2363
+ for (let attempt2 = 0; attempt2 < attempts; attempt2 += 1) {
1119
2364
  await deps.sleep(interval);
1120
2365
  let reachable = false;
1121
2366
  try {
@@ -1533,7 +2778,7 @@ async function runInspect(context) {
1533
2778
 
1534
2779
  // src/commands/domains.ts
1535
2780
  var defaults3 = {
1536
- sleep: (ms) => new Promise((resolve4) => setTimeout(resolve4, ms)),
2781
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
1537
2782
  now: () => Date.now()
1538
2783
  };
1539
2784
  var FIRST_DELAY_MS = 5e3;
@@ -1776,8 +3021,8 @@ async function runDomainsRemove(context) {
1776
3021
  }
1777
3022
 
1778
3023
  // src/commands/env.ts
1779
- import { existsSync, writeFileSync as writeFileSync3 } from "fs";
1780
- import { join as join3 } from "path";
3024
+ import { existsSync, writeFileSync as writeFileSync4 } from "fs";
3025
+ import { join as join4 } from "path";
1781
3026
  var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
1782
3027
  var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1783
3028
  function parseTarget(value) {
@@ -1880,12 +3125,12 @@ async function runEnvPull(context) {
1880
3125
  const vars = Object.fromEntries(
1881
3126
  selected.map((row) => [row.key, row.value ?? ""])
1882
3127
  );
1883
- const path = join3(context.cwd, file);
3128
+ const path = join4(context.cwd, file);
1884
3129
  if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
1885
3130
  const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
1886
3131
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
1887
3132
  }
1888
- writeFileSync3(
3133
+ writeFileSync4(
1889
3134
  path,
1890
3135
  `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
1891
3136
  ${serializeDotenv(vars)}`,
@@ -1898,13 +3143,21 @@ ${serializeDotenv(vars)}`,
1898
3143
  }
1899
3144
 
1900
3145
  // src/commands/gen.ts
1901
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
3146
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
1902
3147
  import { dirname as dirname2, resolve as resolve3 } from "path";
3148
+ var LANG_MESSAGE = "naro gen types writes TypeScript only. Pass --lang typescript, or leave --lang out.";
1903
3149
  async function runGenTypes(context) {
3150
+ const { lang } = context.flags;
3151
+ if (lang !== void 0 && lang !== "typescript")
3152
+ throw new NaroError("VALIDATION_FAILED", LANG_MESSAGE);
1904
3153
  const client = await context.client();
1905
3154
  const projectId = await context.resolveProjectId();
1906
3155
  const tables = await client.db.tables(projectId);
1907
- const types = generateTypes(tables);
3156
+ const read = await policiesForTypes(client, projectId);
3157
+ const types = generateTypes(
3158
+ tables,
3159
+ read.policies === void 0 ? {} : { policies: read.policies }
3160
+ );
1908
3161
  const undescribed = tables.flatMap(
1909
3162
  (table) => table.error === void 0 ? [] : [{ name: table.name, error: table.error }]
1910
3163
  );
@@ -1912,11 +3165,18 @@ async function runGenTypes(context) {
1912
3165
  context.output.warn(
1913
3166
  `\u26A0 ${printable(name)} could not be described and was generated as an empty interface: ${printable(error)}`
1914
3167
  );
1915
- const extra = undescribed.length > 0 ? { undescribed } : {};
3168
+ if (read.policies === void 0)
3169
+ context.output.warn(
3170
+ `\u26A0 The backend's policies could not be read (${printable(read.reason)}), so owner columns stay required in Insert.`
3171
+ );
3172
+ const extra = {
3173
+ ...undescribed.length > 0 ? { undescribed } : {},
3174
+ ...read.policies === void 0 ? { policies: "unavailable", policiesReason: read.reason } : {}
3175
+ };
1916
3176
  if (context.flags.output) {
1917
3177
  const path = resolve3(context.cwd, context.flags.output);
1918
- mkdirSync3(dirname2(path), { recursive: true });
1919
- writeFileSync4(path, types);
3178
+ mkdirSync4(dirname2(path), { recursive: true });
3179
+ writeFileSync5(path, types);
1920
3180
  context.output.result(
1921
3181
  {
1922
3182
  projectId,
@@ -1938,7 +3198,7 @@ async function runGenTypes(context) {
1938
3198
  // src/commands/logs.ts
1939
3199
  var FOLLOW_INTERVAL_MS = 3e3;
1940
3200
  function sleep(ms) {
1941
- return new Promise((resolve4) => setTimeout(resolve4, ms));
3201
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
1942
3202
  }
1943
3203
  function formatBuildLog(log) {
1944
3204
  return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
@@ -2062,6 +3322,666 @@ async function runLogs(context) {
2062
3322
  await buildLogs(context, client, target);
2063
3323
  }
2064
3324
 
3325
+ // src/commands/policy.ts
3326
+ import { readFileSync as readFileSync3 } from "fs";
3327
+ import { resolve as resolve4 } from "path";
3328
+
3329
+ // ../../packages/naro-sdk/src/query/filters.ts
3330
+ var MAX_FILTER_DEPTH = 4;
3331
+ var OPERATORS = {
3332
+ eq: "eq",
3333
+ neq: "neq",
3334
+ gt: "gt",
3335
+ gte: "gte",
3336
+ lt: "lt",
3337
+ lte: "lte",
3338
+ in: "in",
3339
+ is: "is",
3340
+ like: "like",
3341
+ ilike: "ilike",
3342
+ contains: "contains",
3343
+ containedBy: "containedBy",
3344
+ overlaps: "overlaps",
3345
+ cs: "contains",
3346
+ cd: "containedBy",
3347
+ ov: "overlaps"
3348
+ };
3349
+ function operatorOf(name) {
3350
+ return typeof name === "string" && Object.hasOwn(OPERATORS, name) ? OPERATORS[name] : void 0;
3351
+ }
3352
+ var Refusal = class extends Error {
3353
+ };
3354
+ function refuse(reason) {
3355
+ throw new Refusal(reason);
3356
+ }
3357
+ var LIST_HINT = {
3358
+ in: ["(", ")", "needs a list in parentheses, like in.(a,b),"],
3359
+ array: ["{", "}", "needs a list in braces, like cs.{a,b},"]
3360
+ };
3361
+ function isSpace(char) {
3362
+ return char !== "" && char.trim() === "";
3363
+ }
3364
+ function looseWord(text) {
3365
+ return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "");
3366
+ }
3367
+ var Parser = class {
3368
+ constructor(text, tokens = {}) {
3369
+ this.text = text;
3370
+ this.tokens = tokens;
3371
+ }
3372
+ index = 0;
3373
+ /**
3374
+ * A token at the next value, followed only by spaces and then one of
3375
+ * `stops` or the end — `auth.uid()x` is text, not the token.
3376
+ */
3377
+ token(stops) {
3378
+ this.skipSpaces();
3379
+ const at = this.index;
3380
+ for (const [word, value] of Object.entries(this.tokens)) {
3381
+ if (!this.text.startsWith(word, at)) continue;
3382
+ let end = at + word.length;
3383
+ while (isSpace(this.text[end] ?? "")) end++;
3384
+ const next = this.text[end] ?? "";
3385
+ if (next !== "" && !stops.includes(next)) continue;
3386
+ this.index = end;
3387
+ return { value, word, at };
3388
+ }
3389
+ return void 0;
3390
+ }
3391
+ at() {
3392
+ return this.text[this.index] ?? "";
3393
+ }
3394
+ /**
3395
+ * An unquoted value that is one of the tokens misspelled — `auth.uid`,
3396
+ * `AUTH.UID` — is refused: read as text it would compare with the words
3397
+ * themselves (사용자 리뷰 반영). Quoted, it is the text.
3398
+ */
3399
+ notNearToken(value, at) {
3400
+ const loose = looseWord(value);
3401
+ for (const word of Object.keys(this.tokens))
3402
+ if (loose !== "" && loose === looseWord(word))
3403
+ refuse(
3404
+ `has ${JSON.stringify(value)} ${this.where(at)}: did you mean ${word}? Quote it to mean the text`
3405
+ );
3406
+ }
3407
+ /** `scalar`, with an unquoted value held to notNearToken. */
3408
+ bareOrQuoted(stops) {
3409
+ this.skipSpaces();
3410
+ const at = this.index;
3411
+ const quoted = this.at() === '"';
3412
+ const text = this.scalar(stops);
3413
+ if (!quoted) this.notNearToken(text, at);
3414
+ return { text, quoted };
3415
+ }
3416
+ where(index = this.index) {
3417
+ return `at character ${String(index + 1)}`;
3418
+ }
3419
+ skipSpaces() {
3420
+ while (isSpace(this.at())) this.index++;
3421
+ }
3422
+ /** `item ("," item)*`, up to a `)` or the end, inside `level` groups. */
3423
+ list(level) {
3424
+ const nodes = [];
3425
+ for (; ; ) {
3426
+ nodes.push(this.item(level));
3427
+ this.skipSpaces();
3428
+ const char = this.at();
3429
+ if (char === ",") this.index++;
3430
+ else if (char === ")" || char === "") return nodes;
3431
+ else refuse(`has unexpected text ${this.where()}`);
3432
+ }
3433
+ }
3434
+ item(level) {
3435
+ this.skipSpaces();
3436
+ const start = this.index;
3437
+ const char = this.at();
3438
+ if (char === "" || char === "," || char === ")")
3439
+ refuse(`has an empty item ${this.where()}`);
3440
+ const group = /^(not\.)?(and|or)\(/.exec(this.text.slice(start));
3441
+ if (!group) return this.leaf(level);
3442
+ const negated = group[1] !== void 0;
3443
+ const inner = level + (negated ? 2 : 1);
3444
+ if (inner > MAX_FILTER_DEPTH)
3445
+ refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
3446
+ const open = start + group[0].length - 1;
3447
+ this.index = open + 1;
3448
+ const children = this.list(inner);
3449
+ if (this.at() !== ")") refuse(`has an unclosed "(" ${this.where(open)}`);
3450
+ this.index++;
3451
+ const node = group[2] === "or" ? { or: children } : { and: children };
3452
+ return negated ? { not: node } : node;
3453
+ }
3454
+ /** `column.[not.]operator.value`. */
3455
+ leaf(level) {
3456
+ const start = this.index;
3457
+ const column = this.at() === '"' ? this.quoted() : this.word().trim();
3458
+ if (column === "" || this.at() !== ".")
3459
+ refuse(`needs column.operator.value ${this.where(start)}`);
3460
+ this.index++;
3461
+ let nameAt = this.index;
3462
+ let name = this.word();
3463
+ let negated = false;
3464
+ if (name === "not" && this.at() === ".") {
3465
+ if (level + 1 > MAX_FILTER_DEPTH)
3466
+ refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
3467
+ negated = true;
3468
+ this.index++;
3469
+ nameAt = this.index;
3470
+ name = this.word();
3471
+ }
3472
+ const operator = operatorOf(name);
3473
+ if (this.at() !== ".") {
3474
+ if (name !== "" && operator === void 0)
3475
+ refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
3476
+ refuse(`needs column.operator.value ${this.where(start)}`);
3477
+ }
3478
+ if (operator === void 0)
3479
+ refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
3480
+ this.index++;
3481
+ const leaf = { operator, column, value: this.value(operator) };
3482
+ return negated ? { not: leaf } : leaf;
3483
+ }
3484
+ /** Up to the next `.`, `,`, `(`, `)` or the end. */
3485
+ word() {
3486
+ const start = this.index;
3487
+ while (this.index < this.text.length && !".,()".includes(this.at()))
3488
+ this.index++;
3489
+ return this.text.slice(start, this.index);
3490
+ }
3491
+ /** `"…"`, with `\` making the next character literal. */
3492
+ quoted() {
3493
+ const start = this.index;
3494
+ this.index++;
3495
+ let text = "";
3496
+ for (; ; ) {
3497
+ if (this.index >= this.text.length)
3498
+ refuse(`has an unclosed quote ${this.where(start)}`);
3499
+ const char = this.at();
3500
+ this.index++;
3501
+ if (char === '"') return text;
3502
+ if (char === "\\" && this.index < this.text.length) {
3503
+ text += this.at();
3504
+ this.index++;
3505
+ } else text += char;
3506
+ }
3507
+ }
3508
+ /** A quoted value, or everything up to `stops`, trimmed. */
3509
+ scalar(stops) {
3510
+ this.skipSpaces();
3511
+ if (this.at() === '"') {
3512
+ const text = this.quoted();
3513
+ this.skipSpaces();
3514
+ if (this.at() !== "" && !stops.includes(this.at()))
3515
+ refuse(`has text after a closing quote ${this.where()}`);
3516
+ return text;
3517
+ }
3518
+ const start = this.index;
3519
+ while (this.index < this.text.length && !stops.includes(this.at()))
3520
+ this.index++;
3521
+ return this.text.slice(start, this.index).trim();
3522
+ }
3523
+ /**
3524
+ * `(a,b)` for `in`: text values, except that an unquoted `true` or `false`
3525
+ * is a boolean (사용자 리뷰 반영, as in `eq`). `{a,b}` for the array
3526
+ * operators: text, except that an unquoted exact decimal is a number
3527
+ * (적대적 리뷰 반영, narrowed by 사용자 리뷰 반영) — json_each's values
3528
+ * have no type affinity to turn the text "1" into 1, so as text `cs.{1}`
3529
+ * matched no numeric array and `not.ov.{1}` matched every row. Quoted,
3530
+ * `"1"` and `"true"` stay text.
3531
+ */
3532
+ listValue(kind) {
3533
+ const [open, close, hint] = LIST_HINT[kind];
3534
+ this.skipSpaces();
3535
+ const openAt = this.index;
3536
+ if (this.at() !== open) refuse(`${hint} ${this.where()}`);
3537
+ this.index++;
3538
+ this.skipSpaces();
3539
+ const values = [];
3540
+ if (this.at() === close) {
3541
+ this.index++;
3542
+ return values;
3543
+ }
3544
+ for (; ; ) {
3545
+ this.skipSpaces();
3546
+ const itemAt = this.index;
3547
+ const token = this.token(`,${close}`);
3548
+ if (token) values.push(token.value);
3549
+ else {
3550
+ const { text: value, quoted } = this.bareOrQuoted(`,${close}`);
3551
+ if (!quoted && value === "") {
3552
+ if (this.index >= this.text.length)
3553
+ refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
3554
+ refuse(`has an empty item ${this.where(itemAt)}`);
3555
+ }
3556
+ values.push(
3557
+ quoted ? value : kind === "array" ? exactNumber(value) : truth(value)
3558
+ );
3559
+ }
3560
+ if (this.index >= this.text.length)
3561
+ refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
3562
+ const char = this.at();
3563
+ this.index++;
3564
+ if (char === close) return values;
3565
+ }
3566
+ }
3567
+ /** The value after `operator.`. */
3568
+ value(operator) {
3569
+ const start = this.index;
3570
+ switch (operator) {
3571
+ case "in":
3572
+ return this.listValue("in");
3573
+ case "contains":
3574
+ case "containedBy":
3575
+ case "overlaps":
3576
+ return this.listValue("array");
3577
+ case "is":
3578
+ return keyword(this.scalar(",)"), start);
3579
+ case "like":
3580
+ case "ilike": {
3581
+ const token = this.token(",)");
3582
+ if (token)
3583
+ refuse(
3584
+ `cannot use ${token.word} with ${operator} ${this.where(token.at)}`
3585
+ );
3586
+ return likePattern(this.bareOrQuoted(",)").text);
3587
+ }
3588
+ case "eq":
3589
+ case "neq": {
3590
+ const token = this.token(",)");
3591
+ if (token) return token.value;
3592
+ const { text, quoted } = this.bareOrQuoted(",)");
3593
+ return quoted ? text : truth(text);
3594
+ }
3595
+ default: {
3596
+ const token = this.token(",)");
3597
+ return token ? token.value : this.bareOrQuoted(",)").text;
3598
+ }
3599
+ }
3600
+ }
3601
+ };
3602
+ function likePattern(pattern) {
3603
+ return pattern.replaceAll("*", "%");
3604
+ }
3605
+ function truth(text) {
3606
+ const word = text.toLowerCase();
3607
+ if (word === "true") return true;
3608
+ if (word === "false") return false;
3609
+ return text;
3610
+ }
3611
+ var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
3612
+ function exactNumber(text) {
3613
+ if (!DECIMAL.test(text)) return text;
3614
+ const number = Number(text);
3615
+ if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
3616
+ const trimmed = text.replace(/\.?0+$/, "");
3617
+ return String(number) === trimmed ? number : text;
3618
+ }
3619
+ function quoteFilterValue(value) {
3620
+ const text = String(value);
3621
+ return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
3622
+ }
3623
+ function keyword(text, at) {
3624
+ const word = text.toLowerCase();
3625
+ if (word === "null") return null;
3626
+ if (word === "true") return true;
3627
+ if (word === "false") return false;
3628
+ return refuse(
3629
+ `needs null, true or false after is at character ${String(at + 1)}`
3630
+ );
3631
+ }
3632
+ function attempt(parse) {
3633
+ try {
3634
+ return { ok: true, node: parse() };
3635
+ } catch (error) {
3636
+ if (error instanceof Refusal) return { ok: false, reason: error.message };
3637
+ throw error;
3638
+ }
3639
+ }
3640
+ function parseLogic(text, kind, options = {}) {
3641
+ if (typeof text !== "string")
3642
+ return { ok: false, reason: "must be a filter string or a function" };
3643
+ if (text.trim() === "") return { ok: false, reason: "must not be empty" };
3644
+ return attempt(() => {
3645
+ if (options.group === false) {
3646
+ const alone = items(text, options.tokens, 0);
3647
+ if (alone.length === 1 && alone[0]) return alone[0];
3648
+ }
3649
+ const nodes = items(text, options.tokens, 1);
3650
+ return kind === "or" ? { or: nodes } : { and: nodes };
3651
+ });
3652
+ }
3653
+ function items(text, tokens, level) {
3654
+ const parser = new Parser(text, tokens);
3655
+ const nodes = parser.list(level);
3656
+ if (parser.at() === ")") refuse(`has an unmatched ")" ${parser.where()}`);
3657
+ return nodes;
3658
+ }
3659
+
3660
+ // src/commands/policy.ts
3661
+ var SET_USAGE = "Usage: naro policy set <table> [--select <rule>] [--insert <rule>] [--update <rule>] [--delete <rule>] [--owner <column>] [--select-when <filter>] [--insert-when <filter>] [--update-when <filter>] [--update-check <filter>] [--delete-when <filter>] [--signed-in] | --from-file <policy.json>";
3662
+ var REMOVE_USAGE = "Usage: naro policy remove <table>";
3663
+ var RULE_FLAGS = {
3664
+ select: "selectRule",
3665
+ insert: "insertRule",
3666
+ update: "updateRule",
3667
+ delete: "deleteRule"
3668
+ };
3669
+ var WHEN_FLAGS = {
3670
+ select: { key: "selectWhen", tree: "using" },
3671
+ insert: { key: "insertWhen", tree: "check" },
3672
+ update: { key: "updateWhen", tree: "using" },
3673
+ delete: { key: "deleteWhen", tree: "using" }
3674
+ };
3675
+ var AUTH_UID = "auth.uid()";
3676
+ var OTHER_FLAGS = [
3677
+ "selectRule",
3678
+ "insertRule",
3679
+ "updateRule",
3680
+ "deleteRule",
3681
+ "ownerColumn",
3682
+ "selectWhen",
3683
+ "insertWhen",
3684
+ "updateWhen",
3685
+ "updateCheck",
3686
+ "deleteWhen",
3687
+ "signedIn"
3688
+ ];
3689
+ function isRule(value) {
3690
+ return POLICY_RULES.includes(value);
3691
+ }
3692
+ function refuse2(message) {
3693
+ throw new NaroError("VALIDATION_FAILED", message);
3694
+ }
3695
+ function flagName(key) {
3696
+ return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
3697
+ }
3698
+ function conditionTree(flag, text) {
3699
+ const parsed = parseLogic(text, "and", {
3700
+ tokens: { [AUTH_UID]: { auth: "uid" } },
3701
+ group: false
3702
+ });
3703
+ if (!parsed.ok) refuse2(`${flag} ${parsed.reason}.`);
3704
+ const tree = parsed.node;
3705
+ const checked = parsePolicyNode(tree, "");
3706
+ if (!checked.ok) {
3707
+ const path = checked.path.replace(/^\./, "");
3708
+ refuse2(
3709
+ path === "" ? `${flag} ${checked.reason}.` : `${flag}: ${path} ${checked.reason}.`
3710
+ );
3711
+ }
3712
+ return tree;
3713
+ }
3714
+ function conditionsFrom(flags) {
3715
+ const conditions = {};
3716
+ for (const action of POLICY_ACTIONS) {
3717
+ const { key, tree } = WHEN_FLAGS[action];
3718
+ const text = flags[key];
3719
+ if (text === void 0) continue;
3720
+ const flag = flagName(key);
3721
+ if (flags[RULE_FLAGS[action]] !== void 0)
3722
+ refuse2(`--${action} and ${flag} cannot be used together.`);
3723
+ const condition = { [tree]: conditionTree(flag, text) };
3724
+ if (action === "update" && flags.updateCheck !== void 0)
3725
+ condition.check = conditionTree("--update-check", flags.updateCheck);
3726
+ if (flags.signedIn) condition.role = "authenticated";
3727
+ conditions[action] = condition;
3728
+ }
3729
+ if (flags.updateCheck !== void 0 && flags.updateWhen === void 0)
3730
+ refuse2("--update-check needs --update-when.");
3731
+ if (flags.signedIn && Object.keys(conditions).length === 0)
3732
+ refuse2(
3733
+ "--signed-in needs --select-when, --insert-when, --update-when or --delete-when."
3734
+ );
3735
+ return conditions;
3736
+ }
3737
+ function patchFromFile(context, path, table) {
3738
+ let text;
3739
+ try {
3740
+ text = readFileSync3(resolve4(context.cwd, path), "utf8");
3741
+ } catch {
3742
+ refuse2(`Could not read ${path}.`);
3743
+ }
3744
+ let body;
3745
+ try {
3746
+ body = JSON.parse(text);
3747
+ } catch {
3748
+ refuse2(`${path} is not JSON.`);
3749
+ }
3750
+ if (typeof body !== "object" || body === null || Array.isArray(body))
3751
+ refuse2(`${path} must hold a JSON object.`);
3752
+ const { table: named, ...patch } = body;
3753
+ if (Object.hasOwn(body, "table")) {
3754
+ if (typeof named !== "string")
3755
+ refuse2(`${path}: table must be a string, the table's name.`);
3756
+ if (table !== void 0 && asciiLower(named) !== asciiLower(table))
3757
+ refuse2(
3758
+ `${path} is the policy of ${printable(named)}, not ${printable(table)}. Remove its "table" to set it on ${printable(table)}.`
3759
+ );
3760
+ }
3761
+ const allowed = /* @__PURE__ */ new Set([...POLICY_ACTIONS, "ownerColumn"]);
3762
+ if (Object.keys(patch).some((key) => !allowed.has(key)))
3763
+ refuse2(
3764
+ `${path}: Only table, select, insert, update, delete and ownerColumn can be set.`
3765
+ );
3766
+ return patch;
3767
+ }
3768
+ function asciiLower(text) {
3769
+ return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
3770
+ }
3771
+ function policyPatch(flags, context, table) {
3772
+ if (flags.fromFile !== void 0) {
3773
+ if (OTHER_FLAGS.some(
3774
+ (key) => flags[key] !== void 0 && flags[key] !== false
3775
+ ))
3776
+ refuse2(
3777
+ "--from-file takes the whole policy: leave out the other policy options."
3778
+ );
3779
+ if (!context) refuse2(SET_USAGE);
3780
+ return patchFromFile(context, flags.fromFile, table);
3781
+ }
3782
+ const patch = {};
3783
+ for (const action of POLICY_ACTIONS) {
3784
+ const value = flags[RULE_FLAGS[action]];
3785
+ if (value === void 0) continue;
3786
+ if (!isRule(value))
3787
+ refuse2(`--${action} must be one of ${POLICY_RULES.join(", ")}.`);
3788
+ patch[action] = value;
3789
+ }
3790
+ Object.assign(patch, conditionsFrom(flags));
3791
+ if (flags.ownerColumn !== void 0) {
3792
+ if (flags.ownerColumn.trim() === "") refuse2("--owner must name a column.");
3793
+ patch.ownerColumn = flags.ownerColumn;
3794
+ }
3795
+ if (Object.keys(patch).length === 0)
3796
+ refuse2(
3797
+ `Set at least one of --select, --insert, --update, --delete, --owner, --select-when, --insert-when, --update-when, --delete-when or --from-file. ${SET_USAGE}`
3798
+ );
3799
+ return patch;
3800
+ }
3801
+ var SHORT = {
3802
+ contains: "cs",
3803
+ containedBy: "cd",
3804
+ overlaps: "ov"
3805
+ };
3806
+ function isAuthUid(value) {
3807
+ return typeof value === "object" && value !== null && value.auth === "uid";
3808
+ }
3809
+ function nearAuthUid(text) {
3810
+ return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "") === "auth.uid";
3811
+ }
3812
+ var DECIMAL2 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
3813
+ function readsAsNumber(value) {
3814
+ const text = String(value);
3815
+ if (!DECIMAL2.test(text) || !Object.is(Number(text), value)) return false;
3816
+ return text.includes(".") || Number.isSafeInteger(value);
3817
+ }
3818
+ function stringText(value, slot) {
3819
+ if (slot === "is") return void 0;
3820
+ if (slot === "like" && value.includes("*")) return void 0;
3821
+ const bare = value !== "" && value.trim() === value && !/[,()"{}\\]/.test(value) && !/^(?:true|false|null)$/i.test(value) && !nearAuthUid(value) && !(slot === "array" && DECIMAL2.test(value));
3822
+ return bare ? value : quoteFilterValue(value);
3823
+ }
3824
+ function valueText(value, slot) {
3825
+ if (isAuthUid(value))
3826
+ return slot === "like" || slot === "is" ? void 0 : AUTH_UID;
3827
+ if (value === null) return slot === "is" ? "null" : void 0;
3828
+ if (typeof value === "boolean")
3829
+ return slot === "is" || slot === "eq" || slot === "in" ? String(value) : void 0;
3830
+ if (typeof value === "number")
3831
+ return slot === "array" && readsAsNumber(value) ? String(value) : void 0;
3832
+ if (typeof value === "string") return stringText(value, slot);
3833
+ return void 0;
3834
+ }
3835
+ function columnText(name) {
3836
+ if (name === "") return void 0;
3837
+ return /[.,()"\s]/.test(name) ? quoteFilterValue(name) : name;
3838
+ }
3839
+ function slotOf(operator) {
3840
+ if (operator === "eq" || operator === "neq") return "eq";
3841
+ if (operator === "like" || operator === "ilike") return "like";
3842
+ if (operator === "is") return "is";
3843
+ return "ordered";
3844
+ }
3845
+ function leafText(leaf, negated) {
3846
+ const column = columnText(leaf.column);
3847
+ if (column === void 0) return void 0;
3848
+ const prefix = `${column}.${negated ? "not." : ""}`;
3849
+ const short = SHORT[leaf.operator];
3850
+ if (leaf.operator === "in" || short !== void 0) {
3851
+ if (!Array.isArray(leaf.value)) return void 0;
3852
+ const slot = short === void 0 ? "in" : "array";
3853
+ const items2 = leaf.value.map((item) => valueText(item, slot));
3854
+ if (items2.some((item) => item === void 0)) return void 0;
3855
+ const list = items2.join(",");
3856
+ return slot === "in" ? `${prefix}in.(${list})` : `${prefix}${String(short)}.{${list}}`;
3857
+ }
3858
+ if (Array.isArray(leaf.value)) return void 0;
3859
+ const text = valueText(leaf.value, slotOf(leaf.operator));
3860
+ return text === void 0 ? void 0 : `${prefix}${leaf.operator}.${text}`;
3861
+ }
3862
+ function groupText(kind, children) {
3863
+ const texts = children.map((child) => filterText(child, false));
3864
+ if (texts.length === 0 || texts.some((text) => text === void 0))
3865
+ return void 0;
3866
+ return `${kind}(${texts.join(",")})`;
3867
+ }
3868
+ function filterText(node, top = true) {
3869
+ if ("operator" in node) return leafText(node, false);
3870
+ if ("not" in node) {
3871
+ const inner = node.not;
3872
+ if ("operator" in inner) return leafText(inner, true);
3873
+ if ("or" in inner) return negatedText(groupText("or", inner.or));
3874
+ if ("and" in inner) return negatedText(groupText("and", inner.and));
3875
+ return void 0;
3876
+ }
3877
+ if ("or" in node) return groupText("or", node.or);
3878
+ if (top && node.and.length > 1) {
3879
+ const texts = node.and.map((child) => filterText(child, false));
3880
+ return texts.some((text) => text === void 0) ? void 0 : texts.join(",");
3881
+ }
3882
+ return groupText("and", node.and);
3883
+ }
3884
+ function negatedText(text) {
3885
+ return text === void 0 ? void 0 : `not.${text}`;
3886
+ }
3887
+ function isInvalid(value) {
3888
+ return typeof value === "object" && "invalid" in value;
3889
+ }
3890
+ function cellOf(value) {
3891
+ if (typeof value === "string") return value;
3892
+ return isInvalid(value) ? "invalid" : "condition";
3893
+ }
3894
+ function conditionText(action, value) {
3895
+ if (typeof value === "string") return value;
3896
+ if (isInvalid(value))
3897
+ return `cannot be read (${value.invalid}); it denies everyone but the service key`;
3898
+ const using = value.using ? filterText(value.using) : "";
3899
+ const check = value.check ? filterText(value.check) : "";
3900
+ if (using === void 0 || check === void 0)
3901
+ return `as JSON ${JSON.stringify(value)}`;
3902
+ const parts = [];
3903
+ if (using !== "") parts.push(`using ${using}`);
3904
+ if (check !== "") parts.push(`check ${check}`);
3905
+ else if (action === "update") parts.push("check = using");
3906
+ const text = parts.join("; ");
3907
+ return value.role === "authenticated" ? `${text} (signed-in users only)` : text;
3908
+ }
3909
+ function policyTable(policies) {
3910
+ const header = ["Table", ...POLICY_ACTIONS, "Owner column"];
3911
+ const rows = policies.map((policy) => [
3912
+ printable(policy.table),
3913
+ ...POLICY_ACTIONS.map((action) => cellOf(policy[action])),
3914
+ policy.ownerColumn === null ? "-" : printable(policy.ownerColumn)
3915
+ ]);
3916
+ const widths = header.map(
3917
+ (title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0))
3918
+ );
3919
+ const table = [header, ...rows].map(
3920
+ (cells) => cells.map(
3921
+ (cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)
3922
+ ).join(" ").trimEnd()
3923
+ );
3924
+ const lines = policies.flatMap(
3925
+ (policy) => POLICY_ACTIONS.filter((action) => typeof policy[action] !== "string").map(
3926
+ (action) => ({
3927
+ label: `${printable(policy.table)}.${action}`,
3928
+ text: printable(conditionText(action, policy[action]))
3929
+ })
3930
+ )
3931
+ );
3932
+ if (lines.length === 0) return table;
3933
+ const width = Math.max(...lines.map((line) => line.label.length));
3934
+ return [
3935
+ ...table,
3936
+ "",
3937
+ ...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
3938
+ ];
3939
+ }
3940
+ async function runPolicyList(context) {
3941
+ const client = await context.client();
3942
+ const projectId = await context.resolveProjectId();
3943
+ const policies = await client.backend.policies(projectId);
3944
+ context.output.result(
3945
+ { projectId, policies },
3946
+ policies.length === 0 ? [
3947
+ "No policies yet: every table denies anon and signed-in requests.",
3948
+ "Open one with `naro policy set <table> --select public`."
3949
+ ] : [
3950
+ ...policyTable(policies),
3951
+ "",
3952
+ "Tables not listed deny anon and signed-in requests. The service key skips policies."
3953
+ ]
3954
+ );
3955
+ }
3956
+ async function runPolicySet(context) {
3957
+ const [table] = context.positionalOrThrow(1, SET_USAGE);
3958
+ const patch = policyPatch(context.flags, context, table);
3959
+ const client = await context.client();
3960
+ const projectId = await context.resolveProjectId();
3961
+ const policy = await client.backend.setPolicy(
3962
+ projectId,
3963
+ table,
3964
+ patch
3965
+ );
3966
+ context.output.result({ projectId, policy }, [
3967
+ `\u2705 Saved the policy for ${printable(policy.table)}. It takes effect within about 10 seconds.`,
3968
+ ...policyTable([policy])
3969
+ ]);
3970
+ }
3971
+ async function runPolicyRemove(context) {
3972
+ const [table] = context.positionalOrThrow(1, REMOVE_USAGE);
3973
+ await confirmOrAbort(
3974
+ context,
3975
+ `Remove the policy for ${printable(table)}? Anon and signed-in requests to it are denied from then on.`
3976
+ );
3977
+ const client = await context.client();
3978
+ const projectId = await context.resolveProjectId();
3979
+ const policy = await client.backend.removePolicy(projectId, table);
3980
+ context.output.result({ projectId, removed: true, policy }, [
3981
+ `\u2705 Removed the policy for ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.`
3982
+ ]);
3983
+ }
3984
+
2065
3985
  // src/commands/projects.ts
2066
3986
  function productionUrl(subdomain) {
2067
3987
  return `https://${subdomain}.${new URL(controlPlaneUrl()).hostname}`;
@@ -2224,6 +4144,7 @@ Usage:
2224
4144
 
2225
4145
  naro db enable Create the project's database (Cloudflare D1)
2226
4146
  naro db info Database status, size, table count and limits
4147
+ and today's writes against the daily write limit (UTC)
2227
4148
  naro db query <sql> | --file <f> Run SQL against it (reads stdin when piped)
2228
4149
  naro db push [--dry-run] Apply pending migrations from ./migrations (asks first; --yes to skip)
2229
4150
  naro db dump [--output <f>] Download a SQL dump of the database (stdout by default)
@@ -2232,10 +4153,67 @@ Usage:
2232
4153
  | --timestamp <time> [--dry-run] prints the command that undoes it
2233
4154
  naro db pull [--dir <d>] Write the remote schema as migrations/0001_remote_schema.sql
2234
4155
  and record it as applied - a baseline (asks first)
4156
+ naro db reset [--dir <d>] Drop everything, clear the ledger, re-apply ./migrations
4157
+ [--dry-run] [--yes] (asks first; db restore --bookmark undoes it)
4158
+ naro db lint [--strict] Check the schema for what D1 actually trips on
4159
+ (exits 1 on an error, --strict on a warning too)
4160
+ naro db table create <name> --column <spec> [--column <spec>\u2026]
4161
+ [--owner <column>] [--if-not-exists] Create a table without SQL. <spec> is name:type[:modifier\u2026]:
4162
+ types integer, real, text, blob, numeric, boolean, datetime, json;
4163
+ modifiers pk, autoincrement, notnull, unique, default=<v>,
4164
+ references=<table>.<column>, ondelete=cascade|set-null|\u2026
4165
+ --owner adds that column (TEXT NOT NULL) and an owner policy
4166
+ naro db table add-column <table> --column <spec>
4167
+ naro db table rename-column <table> <column> <new-name>
4168
+ naro db table rename <table> <new-name>
4169
+ naro db table drop-column <table> <column> Asks first (--yes to skip)
4170
+ naro db table drop <table> Asks first (--yes to skip)
4171
+ naro db index create <table> <col[,col\u2026]> [--unique] [--name <name>]
4172
+ naro db index drop <name>
4173
+ Each runs as one migration in d1_migrations and is written to ./migrations
4174
+ (--no-write skips the file; --dry-run prints the SQL and changes nothing)
2235
4175
  naro migration new <name> Create migrations/NNNN_<name>.sql (stdin becomes the body when piped)
2236
4176
  naro migration list Local vs applied migrations
2237
4177
  naro migration up [--dry-run] Same as db push
2238
- naro gen types [--output <file>] TypeScript types for the schema (stdout by default; TypeScript only)
4178
+ naro migration repair <name> Fix the ledger alone: mark a migration applied or
4179
+ --status applied|reverted [--yes] remove its row. Its SQL is never run (asks first)
4180
+ naro gen types [--lang typescript] [--output <file>]
4181
+ TypeScript types for the schema, for createClient<Database>()
4182
+ from naro-js (stdout by default)
4183
+
4184
+ naro backend [status] On or off, status, URL and anon key (the service key stays hidden)
4185
+ naro backend enable Turn the backend on: D1, sign-in and the Data API (safe to re-run;
4186
+ resumes a failed run; brings a turned-off one back with the same keys;
4187
+ provision is the same command)
4188
+ naro backend disable Turn it off: in up to about 90 seconds the app's /_naro/* answers 404;
4189
+ data, users and keys are kept (asks first; --yes to skip; re-run
4190
+ finishes a turn-off that stopped half-way)
4191
+ naro backend keys [--reveal] API keys; --reveal prints the service key (a secret; needs database write)
4192
+ naro backend keys rotate --anon|--service
4193
+ Replace a key; the old one stops working at once (asks first)
4194
+ naro backend auth Sign-in settings: email, Google, GitHub, redirects, callback URLs
4195
+ naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin
4196
+ Confirmation and reset emails from your Resend account
4197
+ naro backend auth email remove Turn email off (asks first)
4198
+ naro backend auth oauth set <google|github> --client-id <id> --client-secret-stdin
4199
+ naro backend auth oauth remove <google|github> Turn a provider off (asks first)
4200
+ naro backend auth redirects [list] Where sign-in may send the browser back to (the production
4201
+ hosts always; a preview host only when added)
4202
+ naro backend auth redirects add <url> An origin, https://app.example.com, or only the pages
4203
+ under a path, https://app.example.com/auth/ (http only for localhost)
4204
+ naro backend auth redirects remove <url>
4205
+ Exits 1, changing nothing, when <url> is not in the list
4206
+ naro backend auth require-verification <on|off>
4207
+ Refuse sign-in until the email is confirmed (needs email)
4208
+ naro policy list Who the Data API lets through, per table (no policy = deny)
4209
+ naro policy set <table> [--select <rule>] [--insert <rule>] [--update <rule>] [--delete <rule>]
4210
+ [--owner <column>] Rules: none | public | authenticated | owner (owner needs --owner)
4211
+ [--select-when <filter>] [--insert-when <filter>] [--update-when <filter>]
4212
+ [--update-check <filter>] [--delete-when <filter>] [--signed-in]
4213
+ Or a condition per action, in Supabase filter syntax;
4214
+ auth.uid() is the signed-in user's id
4215
+ [--from-file <policy.json>] Or the whole policy as JSON
4216
+ naro policy remove <table> Back to deny for anon and signed-in requests (asks first)
2239
4217
 
2240
4218
  naro promote <deployment-id> Publish a build to production
2241
4219
  naro rollback [deployment-id] Roll production back to the previous build
@@ -2246,26 +4224,101 @@ Global options:
2246
4224
  --token <t> Access token (or set NARO_TOKEN)
2247
4225
  --project <id> Target project (overrides .naro/project.json)
2248
4226
  --root <dir> App directory inside the repo (monorepos), e.g. apps/web
2249
- --name <name> Project name for a first deploy (defaults to package.json)
4227
+ --name <name> Project name for a first deploy (defaults to package.json); the index name (naro db index create)
2250
4228
  --team <slug> Team scope for project listing/linking
2251
4229
  --cwd <dir> Run as if started in <dir>
2252
4230
  --prod Deploy/target production
2253
4231
  --wait Poll until the domain goes live (naro domains verify)
2254
4232
  --no-www Register the apex alone (naro domains add)
2255
4233
  --file <path> SQL file to run (naro db query)
2256
- --dry-run Show what would change without changing it (naro db push, db restore)
4234
+ --dry-run Show what would change without changing it (naro db push, db restore, db reset, db table, db index)
2257
4235
  --output <file> Write to a file instead of stdout (naro gen types, db dump)
4236
+ --lang <lang> Language of the types; typescript, the default, is the only one (naro gen types)
2258
4237
  --dir <path> Migrations directory (default ./migrations, or <--root>/migrations)
4238
+ --strict Fail on warnings too, not only errors (naro db lint)
2259
4239
  --schema-only Dump the schema without rows (naro db dump)
2260
4240
  --data-only Dump rows without the schema (naro db dump)
2261
4241
  --table <a,b> Dump only these tables, comma-separated (naro db dump)
2262
4242
  --bookmark <id> Restore point: a bookmark from db dump or db restore (naro db restore)
2263
4243
  --timestamp <time> Restore point: RFC 3339 with a timezone, or Unix seconds (naro db restore)
4244
+ --status <s> applied or reverted (naro migration repair)
4245
+ --reveal Print secrets: env values (naro env list), the service key (naro backend keys)
4246
+ --anon | --service Which key to replace (naro backend keys rotate)
4247
+ --provider <p> Email provider; resend is the one (naro backend auth email set)
4248
+ --from <address> Sender, e.g. "App <no-reply@example.com>" (naro backend auth email set)
4249
+ --client-id <id> The OAuth app's client ID (naro backend auth oauth set)
4250
+ --api-key-stdin | --client-secret-stdin
4251
+ Read the secret from stdin (a pipe, or a hidden prompt); never from argv
4252
+ --select <rule> Rule for reads; --insert, --update, --delete likewise (naro policy set)
4253
+ --owner <column> Column that holds the signed-in user's id, for the owner rule (naro policy set, db table create)
4254
+ --select-when <filter> Condition for reads, e.g. 'user_id.eq.auth.uid()'; --insert-when (what a new
4255
+ row must meet), --update-when, --delete-when likewise (naro policy set)
4256
+ --update-check <filter> What an updated row must still meet (default: --update-when)
4257
+ --signed-in The conditions given let signed-in users only (naro policy set)
4258
+ --from-file <path> The policy as JSON: the body PUT \u2026/backend/policies/<table> takes, or a policy --json printed (naro policy set)
4259
+ --column <spec> A column, repeatable: name:type[:modifier\u2026] (naro db table create, add-column)
4260
+ --if-not-exists Leave an existing table as it is (naro db table create)
4261
+ --unique Make the index unique (naro db index create)
4262
+ --no-write Apply, but write no migration file to ./migrations (naro db table, db index)
2264
4263
  --debug Verbose diagnostics
2265
4264
 
2266
4265
  Credentials: \`naro login\` opens a browser (GitHub). CI: NARO_TOKEN=$(naro token).
2267
4266
  Set NARO_CONTROL_PLANE_URL to use a custom control plane (default https://naro.sh).
2268
4267
  `;
4268
+ var TABLE_ACTIONS = {
4269
+ create: runDbTableCreate,
4270
+ "add-column": runDbTableAddColumn,
4271
+ "rename-column": runDbTableRenameColumn,
4272
+ rename: runDbTableRename,
4273
+ "drop-column": runDbTableDropColumn,
4274
+ drop: runDbTableDrop
4275
+ };
4276
+ var INDEX_ACTIONS = {
4277
+ create: runDbIndexCreate,
4278
+ drop: runDbIndexDrop
4279
+ };
4280
+ function routeBackendAuth(words) {
4281
+ const [area, action] = words;
4282
+ const rest = words.slice(2);
4283
+ switch (area) {
4284
+ case void 0:
4285
+ case "status":
4286
+ return words.length <= 1 ? { handler: runBackendAuthStatus, positionals: [] } : null;
4287
+ case "email":
4288
+ if (action === "set")
4289
+ return { handler: runBackendAuthEmailSet, positionals: rest };
4290
+ if (action === "remove" || action === "rm")
4291
+ return { handler: runBackendAuthEmailRemove, positionals: rest };
4292
+ return null;
4293
+ case "oauth":
4294
+ if (action === "set")
4295
+ return { handler: runBackendAuthOAuthSet, positionals: rest };
4296
+ if (action === "remove" || action === "rm")
4297
+ return { handler: runBackendAuthOAuthRemove, positionals: rest };
4298
+ return null;
4299
+ case "redirects":
4300
+ switch (action) {
4301
+ case void 0:
4302
+ case "list":
4303
+ case "ls":
4304
+ return { handler: runBackendAuthRedirectsList, positionals: rest };
4305
+ case "add":
4306
+ return { handler: runBackendAuthRedirectsAdd, positionals: rest };
4307
+ case "remove":
4308
+ case "rm":
4309
+ return { handler: runBackendAuthRedirectsRemove, positionals: rest };
4310
+ default:
4311
+ return null;
4312
+ }
4313
+ case "require-verification":
4314
+ return {
4315
+ handler: runBackendAuthRequireVerification,
4316
+ positionals: words.slice(1)
4317
+ };
4318
+ default:
4319
+ return null;
4320
+ }
4321
+ }
2269
4322
  function route(parsed) {
2270
4323
  const { command, positionals } = parsed;
2271
4324
  const sub = positionals[0];
@@ -2359,6 +4412,22 @@ function route(parsed) {
2359
4412
  return { handler: runDbRestore, positionals: positionals.slice(1) };
2360
4413
  case "pull":
2361
4414
  return { handler: runDbPull, positionals: positionals.slice(1) };
4415
+ case "reset":
4416
+ return { handler: runDbReset, positionals: positionals.slice(1) };
4417
+ case "lint":
4418
+ return { handler: runDbLint, positionals: positionals.slice(1) };
4419
+ case "table":
4420
+ case "index": {
4421
+ const actions = sub === "table" ? TABLE_ACTIONS : INDEX_ACTIONS;
4422
+ const action = positionals[1];
4423
+ if (action === void 0)
4424
+ return {
4425
+ handler: sub === "table" ? runDbTableUsage : runDbIndexUsage,
4426
+ positionals: []
4427
+ };
4428
+ const handler = Object.hasOwn(actions, action) ? actions[action] : void 0;
4429
+ return handler ? { handler, positionals: positionals.slice(2) } : null;
4430
+ }
2362
4431
  default:
2363
4432
  return null;
2364
4433
  }
@@ -2378,6 +4447,60 @@ function route(parsed) {
2378
4447
  };
2379
4448
  case "up":
2380
4449
  return { handler: runMigrationUp, positionals: positionals.slice(1) };
4450
+ case "repair":
4451
+ return {
4452
+ handler: runMigrationRepair,
4453
+ positionals: positionals.slice(1)
4454
+ };
4455
+ default:
4456
+ return null;
4457
+ }
4458
+ case "backend":
4459
+ switch (sub) {
4460
+ case void 0:
4461
+ case "info":
4462
+ case "status":
4463
+ return { handler: runBackendInfo, positionals: positionals.slice(1) };
4464
+ // `provision` is enable's first name (toggle spec §2.6).
4465
+ case "enable":
4466
+ case "provision":
4467
+ return {
4468
+ handler: runBackendEnable,
4469
+ positionals: positionals.slice(1)
4470
+ };
4471
+ case "disable":
4472
+ return {
4473
+ handler: runBackendDisable,
4474
+ positionals: positionals.slice(1)
4475
+ };
4476
+ case "keys":
4477
+ if (positionals[1] === void 0)
4478
+ return { handler: runBackendKeys, positionals: [] };
4479
+ if (positionals[1] === "rotate")
4480
+ return {
4481
+ handler: runBackendKeysRotate,
4482
+ positionals: positionals.slice(2)
4483
+ };
4484
+ return null;
4485
+ case "auth":
4486
+ return routeBackendAuth(positionals.slice(1));
4487
+ default:
4488
+ return null;
4489
+ }
4490
+ case "policy":
4491
+ switch (sub) {
4492
+ case void 0:
4493
+ case "list":
4494
+ case "ls":
4495
+ return { handler: runPolicyList, positionals: positionals.slice(1) };
4496
+ case "set":
4497
+ return { handler: runPolicySet, positionals: positionals.slice(1) };
4498
+ case "remove":
4499
+ case "rm":
4500
+ return {
4501
+ handler: runPolicyRemove,
4502
+ positionals: positionals.slice(1)
4503
+ };
2381
4504
  default:
2382
4505
  return null;
2383
4506
  }
@@ -2403,8 +4526,8 @@ function route(parsed) {
2403
4526
  function version() {
2404
4527
  try {
2405
4528
  const pkg = JSON.parse(
2406
- readFileSync3(
2407
- join4(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
4529
+ readFileSync4(
4530
+ join5(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
2408
4531
  "utf8"
2409
4532
  )
2410
4533
  );
@@ -2440,7 +4563,7 @@ async function main(argv = process.argv.slice(2)) {
2440
4563
  output.fail(
2441
4564
  new NaroError(
2442
4565
  "VALIDATION_FAILED",
2443
- `Unknown option: ${parsed.unknown.join(", ")}. See \`naro help\`.`
4566
+ secretFlagProblem(parsed.unknown) ?? `Unknown option: ${parsed.unknown.join(", ")}. See \`naro help\`.`
2444
4567
  )
2445
4568
  );
2446
4569
  return;