naro-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,24 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ BOOKMARK_FORMAT_MESSAGE,
3
4
  NaroClient,
4
5
  NaroError,
6
+ POLICY_ACTIONS,
7
+ POLICY_RULES,
8
+ RESTORE_OUTCOME_UNKNOWN_CODES,
9
+ buildResetPlan,
10
+ cellText,
5
11
  clearSession,
6
12
  compareMigrations,
7
13
  controlPlaneUrl,
14
+ countLintedTables,
8
15
  createAuthClient,
9
16
  deployDirectory,
10
17
  detectGitInfo,
11
18
  ensureGitignoreHasNaro,
19
+ formatRedirectEntry,
12
20
  generateTypes,
21
+ isBookmark,
13
22
  isExpired,
14
23
  isNaroError,
24
+ isStalledBackend,
15
25
  isTerminalStatus,
26
+ lintSchema,
16
27
  listMigrationFiles,
17
28
  loadStoredSession,
18
29
  login,
30
+ migrationNameMismatch,
31
+ migrationsPendingError,
19
32
  nextMigrationFileName,
20
33
  openBrowser,
21
34
  parseGitHubRemote,
35
+ parsePolicyNode,
36
+ parseRedirectEntry,
37
+ pendingMigrationNames,
38
+ policiesForTypes,
22
39
  projectPath,
23
40
  promptForMissingEnv,
24
41
  readProjectLink,
@@ -28,12 +45,15 @@ import {
28
45
  resolveAuth,
29
46
  resolveMigrationsDir,
30
47
  saveSession,
48
+ schemaMigrationTitle,
31
49
  serializeDotenv,
32
50
  slugify,
33
51
  summarizeStatement,
52
+ systemTablePredicate,
34
53
  toNaroError,
35
54
  writeProjectLink
36
- } from "./chunk-E2M6CQL7.js";
55
+ } from "./chunk-Z757IOF3.js";
56
+ import "./chunk-K3NQKI34.js";
37
57
 
38
58
  // src/args.ts
39
59
  var BOOLEAN_FLAGS = {
@@ -52,7 +72,21 @@ var BOOLEAN_FLAGS = {
52
72
  "--reveal": "reveal",
53
73
  "--wait": "wait",
54
74
  "--no-www": "noWww",
55
- "--dry-run": "dryRun"
75
+ "--dry-run": "dryRun",
76
+ "--strict": "strict",
77
+ "--schema-only": "schemaOnly",
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"
56
90
  };
57
91
  var STRING_FLAGS = {
58
92
  "--token": "token",
@@ -67,8 +101,44 @@ var STRING_FLAGS = {
67
101
  "--level": "level",
68
102
  "--file": "file",
69
103
  "--output": "output",
70
- "--dir": "dir"
104
+ "--lang": "lang",
105
+ "--dir": "dir",
106
+ "--table": "table",
107
+ "--timestamp": "timestamp",
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"
71
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
+ }
138
+ function listFlag(value) {
139
+ if (value === void 0) return [];
140
+ return value.split(",").map((piece) => piece.trim()).filter((piece) => piece.length > 0);
141
+ }
72
142
  function parseArgs(argv) {
73
143
  const flags = {
74
144
  json: false,
@@ -93,6 +163,22 @@ function parseArgs(argv) {
93
163
  continue;
94
164
  }
95
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
+ }
96
182
  const stringKey = STRING_FLAGS[arg] ?? (eq > 0 ? STRING_FLAGS[arg.slice(0, eq)] : void 0);
97
183
  if (stringKey) {
98
184
  if (eq > 0 && STRING_FLAGS[arg.slice(0, eq)]) {
@@ -209,7 +295,7 @@ function isInteractive() {
209
295
  }
210
296
  async function readStdin() {
211
297
  let data = "";
212
- for await (const chunk of process.stdin) data += String(chunk);
298
+ for await (const chunk2 of process.stdin) data += String(chunk2);
213
299
  return data.replace(/\r?\n$/, "");
214
300
  }
215
301
  var TtyPrompter = class {
@@ -220,7 +306,7 @@ var TtyPrompter = class {
220
306
  });
221
307
  try {
222
308
  return await new Promise(
223
- (resolve3) => rl.question(question, resolve3)
309
+ (resolve5) => rl.question(question, resolve5)
224
310
  );
225
311
  } finally {
226
312
  rl.close();
@@ -229,8 +315,8 @@ var TtyPrompter = class {
229
315
  async askHidden(question) {
230
316
  let muted = false;
231
317
  const output = new Writable({
232
- write(chunk, _enc, done) {
233
- if (!muted) process.stdout.write(chunk);
318
+ write(chunk2, _enc, done) {
319
+ if (!muted) process.stdout.write(chunk2);
234
320
  done();
235
321
  }
236
322
  });
@@ -241,7 +327,7 @@ var TtyPrompter = class {
241
327
  });
242
328
  try {
243
329
  const answer = new Promise(
244
- (resolve3) => rl.question(question, resolve3)
330
+ (resolve5) => rl.question(question, resolve5)
245
331
  );
246
332
  muted = true;
247
333
  const value = await answer;
@@ -352,8 +438,8 @@ function createContext(flags, positionals = []) {
352
438
  }
353
439
 
354
440
  // src/router.ts
355
- import { readFileSync as readFileSync3 } from "fs";
356
- import { dirname as dirname2, join as join3 } from "path";
441
+ import { readFileSync as readFileSync4 } from "fs";
442
+ import { dirname as dirname3, join as join5 } from "path";
357
443
  import { fileURLToPath } from "url";
358
444
 
359
445
  // src/commands/auth.ts
@@ -450,127 +536,1761 @@ async function confirmOrAbort(context, question) {
450
536
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
451
537
  }
452
538
 
453
- // src/commands/cancel.ts
454
- async function runCancel(context) {
455
- const [deploymentId] = context.positionalOrThrow(
539
+ // src/commands/migration.ts
540
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
541
+ import { join, relative } from "path";
542
+ var processStdin = {
543
+ stdinIsTty: () => process.stdin.isTTY === true,
544
+ readStdin
545
+ };
546
+ function migrationsDir(context) {
547
+ return resolveMigrationsDir({
548
+ baseDir: context.cwd,
549
+ rootDirectory: context.flags.root,
550
+ dir: context.flags.dir
551
+ });
552
+ }
553
+ function relativeToCwd(context, path) {
554
+ const rel = relative(context.cwd, path);
555
+ return rel === "" ? "." : rel;
556
+ }
557
+ async function appliedMigrations(client, projectId) {
558
+ try {
559
+ return await client.db.migrations(projectId);
560
+ } catch (error) {
561
+ if (isNaroError(error) && error.code === "DATABASE_NOT_READY") return [];
562
+ throw error;
563
+ }
564
+ }
565
+ function printable(text) {
566
+ let out = "";
567
+ for (const char of text) {
568
+ const code = char.charCodeAt(0);
569
+ const control = code < 32 || code >= 127 && code <= 159;
570
+ out += control ? "?" : char;
571
+ }
572
+ return out;
573
+ }
574
+ function warningLine(warning) {
575
+ if (warning === "out-of-order")
576
+ return " \u26A0 older than an applied migration \u2014 db push will still apply it";
577
+ if (warning === "ledger-name-missing-extension")
578
+ return " \u26A0 recorded without .sql in the ledger";
579
+ return null;
580
+ }
581
+ async function runMigrationNew(context, deps = processStdin) {
582
+ const [name] = context.positionalOrThrow(
456
583
  1,
457
- "Usage: naro cancel <deployment-id>"
584
+ "Usage: naro migration new <name>"
458
585
  );
586
+ const dir = migrationsDir(context);
587
+ const fileName = nextMigrationFileName(
588
+ listMigrationFiles(dir),
589
+ name
590
+ );
591
+ mkdirSync(dir, { recursive: true });
592
+ const piped = !deps.stdinIsTty() ? await deps.readStdin() : "";
593
+ const fromStdin = piped.length > 0;
594
+ const body = fromStdin ? piped.endsWith("\n") ? piped : `${piped}
595
+ ` : `-- Migration: ${fileName}
596
+ `;
597
+ const path = join(dir, fileName);
598
+ writeFileSync(path, body, { flag: "wx" });
599
+ const relPath = relativeToCwd(context, path);
600
+ context.output.result({ file: fileName, path: relPath, fromStdin }, [
601
+ `\u2705 Created ${relPath}`
602
+ ]);
603
+ }
604
+ async function runMigrationList(context) {
459
605
  const client = await context.client();
460
606
  const projectId = await context.resolveProjectId();
461
- await confirmOrAbort(context, `Cancel the build ${String(deploymentId)}?`);
462
- const canceled = await client.deployments.cancel(
463
- projectId,
464
- deploymentId
607
+ const dir = migrationsDir(context);
608
+ const local = listMigrationFiles(dir);
609
+ const ledger = await appliedMigrations(client, projectId);
610
+ const migrations = compareMigrations(local, ledger);
611
+ const relDir = relativeToCwd(context, dir);
612
+ const lines = migrations.length === 0 ? ["No migrations yet. Create one with `naro migration new <name>`."] : [
613
+ `Migrations in ${relDir} (${String(migrations.length)}):`,
614
+ ...migrations.flatMap((row) => {
615
+ const line = ` ${row.status.padEnd(12)}${printable(row.name)}${row.appliedAt ? ` ${printable(row.appliedAt)}` : ""}`;
616
+ const warning = warningLine(row.warning);
617
+ return warning ? [line, warning] : [line];
618
+ })
619
+ ];
620
+ context.output.result({ projectId, dir: relDir, migrations }, lines);
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"
465
653
  );
466
- context.output.result(
467
- {
468
- deploymentId: canceled.id,
469
- projectId,
470
- status: canceled.status,
471
- errorCode: canceled.errorCode
472
- },
473
- [`\u2705 Canceled ${canceled.id}`]
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)
474
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
+ }
698
+ async function runMigrationUp(context) {
699
+ const client = await context.client();
700
+ const projectId = await context.resolveProjectId();
701
+ const dir = migrationsDir(context);
702
+ const local = listMigrationFiles(dir);
703
+ const ledger = await appliedMigrations(client, projectId);
704
+ const pending = compareMigrations(local, ledger).filter((row) => row.status === "pending").map((row) => row.name);
705
+ if (pending.length === 0) {
706
+ context.output.result({ projectId, applied: [], pending: [] }, [
707
+ "No pending migrations."
708
+ ]);
709
+ return;
710
+ }
711
+ if (context.flags.dryRun) {
712
+ context.output.result({ projectId, dryRun: true, pending }, [
713
+ `Pending migrations (${String(pending.length)}):`,
714
+ ...pending.map((name) => ` ${printable(name)}`)
715
+ ]);
716
+ return;
717
+ }
718
+ context.output.log(`Pending migrations (${String(pending.length)}):`);
719
+ for (const name of pending) context.output.log(` ${printable(name)}`);
720
+ await confirmOrAbort(
721
+ context,
722
+ `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
723
+ );
724
+ const applied = await applyMigrationFiles(
725
+ context,
726
+ client,
727
+ projectId,
728
+ dir,
729
+ pending
730
+ );
731
+ context.output.result({ projectId, applied, pending: [] }, [
732
+ `\u2705 Applied ${String(applied.length)} migration(s).`
733
+ ]);
475
734
  }
476
735
 
477
- // src/commands/db.ts
478
- import { readFileSync } from "fs";
479
- import { resolve } from "path";
480
- var processStdin = {
481
- stdinIsTty: () => process.stdin.isTTY === true,
482
- readStdin
483
- };
484
- var USAGE = "Usage: naro db query <sql> | --file <path> | (stdin)";
485
- var ROW_CAP_NOTICE = "\u26A0 Output was cut at 10,000 rows. Add a LIMIT to see the rest.";
486
- function humanSize(bytes) {
487
- const units = ["B", "KB", "MB", "GB"];
488
- let value = bytes;
489
- let unit = 0;
490
- while (value >= 1024 && unit < units.length - 1) {
491
- value /= 1024;
492
- unit += 1;
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;
747
+ }
748
+ function sentence(text) {
749
+ const line = printable(text).trim();
750
+ return /[.!?…]$/.test(line) ? line : `${line}.`;
751
+ }
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;
761
+ }
493
762
  }
494
- return `${unit === 0 ? String(value) : value.toFixed(1)} ${units[unit] ?? "B"}`;
495
763
  }
496
- function describeLines(info) {
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";
769
+ }
770
+ function backendLines(info, now = Date.now()) {
497
771
  const lines = [
498
- `Provider: ${info.provider}`,
499
- `Status: ${info.status}`,
500
- `Binding: env.${info.binding}`
772
+ `Backend: ${switchText(info)}`,
773
+ `Status: ${statusText(info, now)}`,
774
+ `URL: ${info.url}`
501
775
  ];
502
- if (info.resourceName)
776
+ if (info.anonKey) lines.push(`Anon key: ${info.anonKey}`);
777
+ if (info.serviceKeyPrefix)
503
778
  lines.push(
504
- `Database: ${info.resourceName}${info.resourceId ? ` (${info.resourceId})` : ""}`
779
+ `Service key: ${info.serviceKeyPrefix}\u2026 (secret \u2014 \`naro backend keys --reveal\` prints it)`
505
780
  );
506
- if (info.cloudflare) {
507
- const size = info.cloudflare.fileSize === null ? "?" : humanSize(info.cloudflare.fileSize);
508
- const tables = info.cloudflare.numTables === null ? "?" : String(info.cloudflare.numTables);
509
- lines.push(`Size: ${size} \xB7 Tables: ${tables}`);
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];
510
787
  }
511
- const { limits } = info;
512
- lines.push(
513
- `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`
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 [];
804
+ }
805
+ }
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)]
514
818
  );
515
- if (info.provider === "none")
516
- lines.push("Run `naro db enable` to create the database.");
517
- return lines;
518
819
  }
519
- async function runDbInfo(context) {
820
+ async function runBackendEnable(context) {
520
821
  const client = await context.client();
521
822
  const projectId = await context.resolveProjectId();
522
- const database = await client.db.info(projectId);
523
- context.output.result({ projectId, database }, describeLines(database));
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
+ ]);
524
862
  }
525
- async function runDbEnable(context) {
863
+ async function runBackendDisable(context) {
864
+ await confirmOrAbort(context, DISABLE_QUESTION);
526
865
  const client = await context.client();
527
866
  const projectId = await context.resolveProjectId();
528
- const database = await client.db.enable(projectId);
529
- context.output.result({ projectId, database }, [
530
- `\u2705 Database enabled \u2014 ${database.resourceName ?? "(pending)"} (${database.status})`,
531
- ...describeLines(database)
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();
1510
+ const read = async () => {
1511
+ try {
1512
+ return await reader.read();
1513
+ } catch (error) {
1514
+ throw new DownloadStreamError(error);
1515
+ }
1516
+ };
1517
+ try {
1518
+ for (; ; ) {
1519
+ const next = await read();
1520
+ if (next.done) return;
1521
+ yield next.value;
1522
+ }
1523
+ } finally {
1524
+ await reader.cancel().catch(() => {
1525
+ });
1526
+ }
1527
+ }
1528
+ async function streamInto(body, destination, end) {
1529
+ let bytes = 0;
1530
+ const counter = new Transform({
1531
+ transform(chunk2, _encoding, done) {
1532
+ bytes += chunk2.length;
1533
+ done(null, chunk2);
1534
+ }
1535
+ });
1536
+ await pipeline(chunksOf(body), counter, destination, { end });
1537
+ return bytes;
1538
+ }
1539
+ function streamFailed(error, url, shown) {
1540
+ return error instanceof DownloadStreamError ? downloadFailed(url, reasonOf(error.original), error.original) : writeFailed(shown, error);
1541
+ }
1542
+ function refuseDirectory(path, shown) {
1543
+ let isDirectory = false;
1544
+ try {
1545
+ isDirectory = statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
1546
+ } catch {
1547
+ }
1548
+ if (isDirectory)
1549
+ throw new NaroError(
1550
+ "VALIDATION_FAILED",
1551
+ `--output ${shown} is a directory. Pass a file path.`
1552
+ );
1553
+ }
1554
+ function makeOutputDirectory(path, shown) {
1555
+ try {
1556
+ mkdirSync2(dirname(path), { recursive: true });
1557
+ } catch (error) {
1558
+ throw writeFailed(shown, error);
1559
+ }
1560
+ }
1561
+ async function downloadToFile(deps, url, path, shown) {
1562
+ const body = await openDownload(deps, url);
1563
+ const partial = join2(
1564
+ dirname(path),
1565
+ `.${basename(path)}.${String(process.pid)}.part`
1566
+ );
1567
+ try {
1568
+ const bytes = await streamInto(body, createWriteStream(partial), true);
1569
+ renameSync(partial, path);
1570
+ return bytes;
1571
+ } catch (error) {
1572
+ try {
1573
+ rmSync(partial, { force: true });
1574
+ } catch {
1575
+ }
1576
+ throw streamFailed(error, url, shown);
1577
+ }
1578
+ }
1579
+ async function downloadToStdout(deps, url) {
1580
+ const body = await openDownload(deps, url);
1581
+ try {
1582
+ return await streamInto(body, deps.stdout, false);
1583
+ } catch (error) {
1584
+ throw streamFailed(error, url, "stdout");
1585
+ }
1586
+ }
1587
+ async function runDbDump(context, overrides = {}) {
1588
+ const deps = { ...defaults, ...overrides };
1589
+ const { flags } = context;
1590
+ if (flags.schemaOnly && flags.dataOnly)
1591
+ throw new NaroError(
1592
+ "VALIDATION_FAILED",
1593
+ "Pass --schema-only or --data-only, not both."
1594
+ );
1595
+ if (flags.json && flags.output === void 0)
1596
+ throw new NaroError(
1597
+ "VALIDATION_FAILED",
1598
+ "--json needs --output: the dump and the JSON result would share stdout."
1599
+ );
1600
+ const tables = listFlag(flags.table);
1601
+ if (flags.table !== void 0 && tables.length === 0)
1602
+ throw new NaroError(
1603
+ "VALIDATION_FAILED",
1604
+ "--table needs at least one table name."
1605
+ );
1606
+ const options = {
1607
+ ...flags.schemaOnly ? { schemaOnly: true } : {},
1608
+ ...flags.dataOnly ? { dataOnly: true } : {},
1609
+ ...tables.length > 0 ? { tables } : {}
1610
+ };
1611
+ const output = flags.output === void 0 ? null : { path: resolve2(context.cwd, flags.output), shown: flags.output };
1612
+ if (output) refuseDirectory(output.path, output.shown);
1613
+ const client = await context.client();
1614
+ const projectId = await context.resolveProjectId();
1615
+ if (output) makeOutputDirectory(output.path, output.shown);
1616
+ context.output.warn(EXPORT_NOTICE);
1617
+ const step = await client.db.export(projectId, options, {
1618
+ sleep: deps.sleep
1619
+ });
1620
+ const file = step.file;
1621
+ if (file === null)
1622
+ throw new NaroError(
1623
+ "DATABASE_UNAVAILABLE",
1624
+ "Could not download the dump (Cloudflare finished the export without a file). Run naro db dump again."
1625
+ );
1626
+ const bytes = output === null ? await downloadToStdout(deps, file.url) : await downloadToFile(deps, file.url, output.path, output.shown);
1627
+ const bookmark = step.atBookmark;
1628
+ if (!context.output.json) {
1629
+ context.output.warn(
1630
+ `\u2713 Wrote ${flags.output ?? "stdout"} (${humanSize(bytes)}) at bookmark ${bookmark}.`
1631
+ );
1632
+ context.output.warn(
1633
+ ` naro db restore --bookmark ${bookmark} returns the database to this point.`
1634
+ );
1635
+ }
1636
+ context.output.result({ output: flags.output, bytes, atBookmark: bookmark });
1637
+ }
1638
+ async function bookmarkNow(context, client, projectId, action = "restoring") {
1639
+ try {
1640
+ const { bookmark } = await client.db.bookmark(projectId);
1641
+ return isBookmark(bookmark) ? bookmark : null;
1642
+ } catch (error) {
1643
+ context.output.debugLog(
1644
+ `Could not read the current bookmark before ${action}: ${reasonOf(error)}`
1645
+ );
1646
+ return null;
1647
+ }
1648
+ }
1649
+ function withWayBack(context, error, before) {
1650
+ if (before === null || !isNaroError(error) || !RESTORE_OUTCOME_UNKNOWN_CODES.has(error.code))
1651
+ return error;
1652
+ if (!context.output.json)
1653
+ context.output.warn(
1654
+ `Before this attempt the database was at bookmark ${before}. If the restore ran anyway, naro db restore --bookmark ${before} undoes it.`
1655
+ );
1656
+ return new NaroError(error.code, error.message, {
1657
+ ...error.status === void 0 ? {} : { status: error.status },
1658
+ details: { ...error.details, previousBookmark: before },
1659
+ cause: error
1660
+ });
1661
+ }
1662
+ async function runDbRestore(context) {
1663
+ const { bookmark, timestamp } = context.flags;
1664
+ const given = bookmark !== void 0 && timestamp === void 0 ? { bookmark } : timestamp !== void 0 && bookmark === void 0 ? { timestamp } : null;
1665
+ if (given === null)
1666
+ throw new NaroError(
1667
+ "VALIDATION_FAILED",
1668
+ "Pass --timestamp <time> or --bookmark <id> (exactly one)."
1669
+ );
1670
+ if ("bookmark" in given && !isBookmark(given.bookmark))
1671
+ throw new NaroError("VALIDATION_FAILED", BOOKMARK_FORMAT_MESSAGE);
1672
+ const client = await context.client();
1673
+ const projectId = await context.resolveProjectId();
1674
+ let target;
1675
+ let at = null;
1676
+ if ("bookmark" in given) {
1677
+ target = given.bookmark;
1678
+ } else {
1679
+ const resolved = await client.db.bookmark(projectId, {
1680
+ timestamp: given.timestamp
1681
+ });
1682
+ target = resolved.bookmark;
1683
+ at = resolved.timestamp ?? given.timestamp;
1684
+ }
1685
+ const label = at === null ? `bookmark ${target}` : `${at} (bookmark ${target})`;
1686
+ if (context.flags.dryRun) {
1687
+ context.output.result(
1688
+ {
1689
+ dryRun: true,
1690
+ bookmark: target,
1691
+ ...at === null ? {} : { timestamp: at }
1692
+ },
1693
+ [`Would restore to ${label}. Nothing was changed.`]
1694
+ );
1695
+ return;
1696
+ }
1697
+ await confirmOrAbort(
1698
+ context,
1699
+ `Restore the project database to ${label}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
1700
+ );
1701
+ const before = await bookmarkNow(context, client, projectId);
1702
+ let restored;
1703
+ try {
1704
+ restored = await client.db.restore(projectId, { bookmark: target });
1705
+ } catch (error) {
1706
+ throw withWayBack(context, error, before);
1707
+ }
1708
+ context.output.result(
1709
+ {
1710
+ bookmark: restored.bookmark,
1711
+ previousBookmark: restored.previousBookmark
1712
+ },
1713
+ [
1714
+ `\u2713 Restored the project database to bookmark ${restored.bookmark}.`,
1715
+ ` To undo: naro db restore --bookmark ${restored.previousBookmark}`
1716
+ ]
1717
+ );
1718
+ }
1719
+ async function runDbPull(context, overrides = {}) {
1720
+ const deps = { ...defaults, ...overrides };
1721
+ const dir = migrationsDir(context);
1722
+ const existing = listMigrationFiles(dir);
1723
+ if (existing.length > 0)
1724
+ throw new NaroError(
1725
+ "VALIDATION_FAILED",
1726
+ `${relativeToCwd(context, dir)} already has ${String(existing.length)} migration file(s). naro db pull only baselines a project without migrations; to see the current schema, run naro db dump --schema-only.`
1727
+ );
1728
+ const name = nextMigrationFileName(existing, "remote_schema");
1729
+ const path = join2(dir, name);
1730
+ const shown = relativeToCwd(context, path);
1731
+ const client = await context.client();
1732
+ const projectId = await context.resolveProjectId();
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}`]
532
1996
  ]);
533
1997
  }
534
- async function readSql(context, deps) {
535
- const positional = context.positionals[0];
536
- let sql;
537
- if (positional !== void 0) {
538
- sql = positional;
539
- } else if (context.flags.file !== void 0) {
540
- try {
541
- sql = readFileSync(resolve(context.cwd, context.flags.file), "utf8");
542
- } catch {
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
543
2097
  throw new NaroError(
544
2098
  "VALIDATION_FAILED",
545
- `Could not read ${context.flags.file}.`
2099
+ `${shown}: unknown modifier ${printable(modifier)}. Use pk, autoincrement, notnull, unique, default=<value>, references=<table>.<column> or ondelete=<action>.`
546
2100
  );
547
- }
548
- } else if (!deps.stdinIsTty()) {
549
- sql = await deps.readStdin();
550
2101
  }
551
- if (sql === void 0 || sql.trim().length === 0)
552
- throw new NaroError("VALIDATION_FAILED", USAGE);
553
- return sql;
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;
554
2111
  }
555
- async function runDbQuery(context, deps = processStdin) {
556
- const sql = await readSql(context, deps);
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;
557
2135
  const client = await context.client();
558
2136
  const projectId = await context.resolveProjectId();
559
- const result = await client.db.query(projectId, { sql });
560
- const lines = [];
561
- result.statements.forEach((statement, index) => {
562
- if (index > 0) lines.push("");
563
- lines.push(...renderRows(statement), summarizeStatement(statement));
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] ?? {})
2146
+ );
2147
+ const result = await client.db.schema(projectId, sent, {
2148
+ ...dryRun ? { dryRun: true } : {},
2149
+ name
564
2150
  });
565
- if (result.truncated) lines.push(ROW_CAP_NOTICE);
566
- context.output.result({ projectId, result }, lines);
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
+ }
2180
+ }
2181
+ for (const warning of allWarnings)
2182
+ context.output.warn(`\u26A0 ${printable(warning)}`);
2183
+ if (fileWarning) context.output.warn(`\u26A0 ${printable(fileWarning)}`);
2184
+ context.output.result(
2185
+ {
2186
+ projectId,
2187
+ ...result,
2188
+ warnings: allWarnings,
2189
+ file,
2190
+ ...fileWarning ? { fileWarning } : {}
2191
+ },
2192
+ [
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))
2197
+ ]
2198
+ );
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 }]);
567
2287
  }
568
2288
 
569
2289
  // src/commands/git.ts
570
- var defaults = {
2290
+ var defaults2 = {
571
2291
  detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
572
2292
  openBrowser,
573
- sleep: (ms) => new Promise((resolve3) => setTimeout(resolve3, ms))
2293
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
574
2294
  };
575
2295
  function connected(context, project) {
576
2296
  const branch = project.defaultBranch;
@@ -589,7 +2309,7 @@ function connected(context, project) {
589
2309
  );
590
2310
  }
591
2311
  async function runGitConnect(context, overrides = {}) {
592
- const deps = { ...defaults, ...overrides };
2312
+ const deps = { ...defaults2, ...overrides };
593
2313
  const { output } = context;
594
2314
  const explicit = context.positionals[0];
595
2315
  let fullName;
@@ -640,7 +2360,7 @@ async function waitForInstall(context, projectId, fullName, deps) {
640
2360
  if (teamSlug) {
641
2361
  const attempts = deps.pollAttempts ?? 60;
642
2362
  const interval = deps.pollIntervalMs ?? 3e3;
643
- for (let attempt = 0; attempt < attempts; attempt += 1) {
2363
+ for (let attempt2 = 0; attempt2 < attempts; attempt2 += 1) {
644
2364
  await deps.sleep(interval);
645
2365
  let reachable = false;
646
2366
  try {
@@ -676,7 +2396,7 @@ async function runGitDisconnect(context) {
676
2396
  ]);
677
2397
  }
678
2398
  async function offerAutoDeploy(context, projectId, overrides) {
679
- const deps = { ...defaults, ...overrides };
2399
+ const deps = { ...defaults2, ...overrides };
680
2400
  if (overrides.canPrompt === false) return;
681
2401
  try {
682
2402
  if (readProjectLink(context.cwd)?.autoDeployDeclined === true) return;
@@ -1057,8 +2777,8 @@ async function runInspect(context) {
1057
2777
  }
1058
2778
 
1059
2779
  // src/commands/domains.ts
1060
- var defaults2 = {
1061
- sleep: (ms) => new Promise((resolve3) => setTimeout(resolve3, ms)),
2780
+ var defaults3 = {
2781
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
1062
2782
  now: () => Date.now()
1063
2783
  };
1064
2784
  var FIRST_DELAY_MS = 5e3;
@@ -1206,7 +2926,7 @@ async function verifyAll(client, projectId, hostname, auto = false) {
1206
2926
  return verified;
1207
2927
  }
1208
2928
  async function runDomainsVerify(context, overrides = {}) {
1209
- const deps = { ...defaults2, ...overrides };
2929
+ const deps = { ...defaults3, ...overrides };
1210
2930
  const client = await context.client();
1211
2931
  const projectId = await context.resolveProjectId();
1212
2932
  const hostname = context.positionals[0];
@@ -1301,8 +3021,8 @@ async function runDomainsRemove(context) {
1301
3021
  }
1302
3022
 
1303
3023
  // src/commands/env.ts
1304
- import { existsSync, writeFileSync } from "fs";
1305
- import { join } from "path";
3024
+ import { existsSync, writeFileSync as writeFileSync4 } from "fs";
3025
+ import { join as join4 } from "path";
1306
3026
  var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
1307
3027
  var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1308
3028
  function parseTarget(value) {
@@ -1370,199 +3090,74 @@ async function runEnvAdd(context) {
1370
3090
  `\u2705 Added ${key} (${target}${isSecret ? ", secret" : ""})`
1371
3091
  ]);
1372
3092
  }
1373
- async function runEnvRemove(context) {
1374
- context.positionalOrThrow(
1375
- 1,
1376
- "Usage: naro env remove KEY [production|preview|all]"
1377
- );
1378
- const [key, targetArg] = context.positionals;
1379
- const target = targetArg === void 0 ? void 0 : parseTarget(targetArg);
1380
- await confirmOrAbort(
1381
- context,
1382
- `Remove ${String(key)}${target ? ` (${target})` : ""} from this project?`
1383
- );
1384
- const client = await context.client();
1385
- const projectId = await context.resolveProjectId();
1386
- const removed = await client.env.remove(projectId, key, target);
1387
- context.output.result({ projectId, key, removed }, [
1388
- `\u2705 Removed ${String(key)} (${String(removed)} entr${removed === 1 ? "y" : "ies"})`
1389
- ]);
1390
- }
1391
- async function runEnvPull(context) {
1392
- const file = context.positionals[0] ?? ".env.local";
1393
- const surface = context.flags.env === "production" ? "production" : "preview";
1394
- if (context.flags.env && !["production", "preview"].includes(context.flags.env))
1395
- throw new NaroError(
1396
- "VALIDATION_FAILED",
1397
- "--env must be production or preview."
1398
- );
1399
- const client = await context.client();
1400
- const projectId = await context.resolveProjectId();
1401
- const rows = await client.env.list(projectId, { reveal: true });
1402
- const selected = rows.filter(
1403
- (row) => row.target === "all" || row.target === surface
1404
- );
1405
- const vars = Object.fromEntries(
1406
- selected.map((row) => [row.key, row.value ?? ""])
1407
- );
1408
- const path = join(context.cwd, file);
1409
- if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
1410
- const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
1411
- if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
1412
- }
1413
- writeFileSync(
1414
- path,
1415
- `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
1416
- ${serializeDotenv(vars)}`,
1417
- { mode: 384 }
1418
- );
1419
- context.output.result(
1420
- { projectId, file, environment: surface, count: selected.length },
1421
- [`\u2705 Pulled ${String(selected.length)} variable(s) into ${file}`]
1422
- );
1423
- }
1424
-
1425
- // src/commands/gen.ts
1426
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
1427
- import { dirname, resolve as resolve2 } from "path";
1428
-
1429
- // src/commands/migration.ts
1430
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1431
- import { join as join2, relative } from "path";
1432
- var processStdin2 = {
1433
- stdinIsTty: () => process.stdin.isTTY === true,
1434
- readStdin
1435
- };
1436
- function migrationsDir(context) {
1437
- return resolveMigrationsDir({
1438
- baseDir: context.cwd,
1439
- rootDirectory: context.flags.root,
1440
- dir: context.flags.dir
1441
- });
1442
- }
1443
- function relativeToCwd(context, path) {
1444
- const rel = relative(context.cwd, path);
1445
- return rel === "" ? "." : rel;
1446
- }
1447
- async function appliedMigrations(client, projectId) {
1448
- try {
1449
- return await client.db.migrations(projectId);
1450
- } catch (error) {
1451
- if (isNaroError(error) && error.code === "DATABASE_NOT_READY") return [];
1452
- throw error;
1453
- }
1454
- }
1455
- function printable(text) {
1456
- let out = "";
1457
- for (const char of text) {
1458
- const code = char.charCodeAt(0);
1459
- const control = code < 32 || code >= 127 && code <= 159;
1460
- out += control ? "?" : char;
1461
- }
1462
- return out;
1463
- }
1464
- function warningLine(warning) {
1465
- if (warning === "out-of-order")
1466
- return " \u26A0 older than an applied migration \u2014 db push will still apply it";
1467
- if (warning === "ledger-name-missing-extension")
1468
- return " \u26A0 recorded without .sql in the ledger";
1469
- return null;
1470
- }
1471
- async function runMigrationNew(context, deps = processStdin2) {
1472
- const [name] = context.positionalOrThrow(
1473
- 1,
1474
- "Usage: naro migration new <name>"
1475
- );
1476
- const dir = migrationsDir(context);
1477
- const fileName = nextMigrationFileName(
1478
- listMigrationFiles(dir),
1479
- name
1480
- );
1481
- mkdirSync(dir, { recursive: true });
1482
- const piped = !deps.stdinIsTty() ? await deps.readStdin() : "";
1483
- const fromStdin = piped.length > 0;
1484
- const body = fromStdin ? piped.endsWith("\n") ? piped : `${piped}
1485
- ` : `-- Migration: ${fileName}
1486
- `;
1487
- const path = join2(dir, fileName);
1488
- writeFileSync2(path, body, { flag: "wx" });
1489
- const relPath = relativeToCwd(context, path);
1490
- context.output.result({ file: fileName, path: relPath, fromStdin }, [
1491
- `\u2705 Created ${relPath}`
1492
- ]);
1493
- }
1494
- async function runMigrationList(context) {
1495
- const client = await context.client();
1496
- const projectId = await context.resolveProjectId();
1497
- const dir = migrationsDir(context);
1498
- const local = listMigrationFiles(dir);
1499
- const ledger = await appliedMigrations(client, projectId);
1500
- const migrations = compareMigrations(local, ledger);
1501
- const relDir = relativeToCwd(context, dir);
1502
- const lines = migrations.length === 0 ? ["No migrations yet. Create one with `naro migration new <name>`."] : [
1503
- `Migrations in ${relDir} (${String(migrations.length)}):`,
1504
- ...migrations.flatMap((row) => {
1505
- const line = ` ${row.status.padEnd(12)}${printable(row.name)}${row.appliedAt ? ` ${printable(row.appliedAt)}` : ""}`;
1506
- const warning = warningLine(row.warning);
1507
- return warning ? [line, warning] : [line];
1508
- })
1509
- ];
1510
- context.output.result({ projectId, dir: relDir, migrations }, lines);
1511
- }
1512
- async function runMigrationUp(context) {
1513
- const client = await context.client();
1514
- const projectId = await context.resolveProjectId();
1515
- const dir = migrationsDir(context);
1516
- const local = listMigrationFiles(dir);
1517
- const ledger = await appliedMigrations(client, projectId);
1518
- const pending = compareMigrations(local, ledger).filter((row) => row.status === "pending").map((row) => row.name);
1519
- if (pending.length === 0) {
1520
- context.output.result({ projectId, applied: [], pending: [] }, [
1521
- "No pending migrations."
1522
- ]);
1523
- return;
1524
- }
1525
- if (context.flags.dryRun) {
1526
- context.output.result({ projectId, dryRun: true, pending }, [
1527
- `Pending migrations (${String(pending.length)}):`,
1528
- ...pending.map((name) => ` ${printable(name)}`)
1529
- ]);
1530
- return;
1531
- }
1532
- context.output.log(`Pending migrations (${String(pending.length)}):`);
1533
- for (const name of pending) context.output.log(` ${printable(name)}`);
3093
+ async function runEnvRemove(context) {
3094
+ context.positionalOrThrow(
3095
+ 1,
3096
+ "Usage: naro env remove KEY [production|preview|all]"
3097
+ );
3098
+ const [key, targetArg] = context.positionals;
3099
+ const target = targetArg === void 0 ? void 0 : parseTarget(targetArg);
1534
3100
  await confirmOrAbort(
1535
3101
  context,
1536
- `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
3102
+ `Remove ${String(key)}${target ? ` (${target})` : ""} from this project?`
1537
3103
  );
1538
- const applied = [];
1539
- for (const name of pending) {
1540
- const sql = readFileSync2(join2(dir, name), "utf8");
1541
- try {
1542
- await client.db.applyMigration(projectId, { name, sql });
1543
- } catch (error) {
1544
- if (!isNaroError(error)) throw error;
1545
- const shown = context.output.json ? name : printable(name);
1546
- throw new NaroError(error.code, `${shown}: ${error.message}`, {
1547
- ...error.status === void 0 ? {} : { status: error.status },
1548
- details: { ...error.details, migration: name, applied },
1549
- cause: error
1550
- });
1551
- }
1552
- context.output.log(`\u2713 ${printable(name)}`);
1553
- applied.push(name);
1554
- }
1555
- context.output.result({ projectId, applied, pending: [] }, [
1556
- `\u2705 Applied ${String(applied.length)} migration(s).`
3104
+ const client = await context.client();
3105
+ const projectId = await context.resolveProjectId();
3106
+ const removed = await client.env.remove(projectId, key, target);
3107
+ context.output.result({ projectId, key, removed }, [
3108
+ `\u2705 Removed ${String(key)} (${String(removed)} entr${removed === 1 ? "y" : "ies"})`
1557
3109
  ]);
1558
3110
  }
3111
+ async function runEnvPull(context) {
3112
+ const file = context.positionals[0] ?? ".env.local";
3113
+ const surface = context.flags.env === "production" ? "production" : "preview";
3114
+ if (context.flags.env && !["production", "preview"].includes(context.flags.env))
3115
+ throw new NaroError(
3116
+ "VALIDATION_FAILED",
3117
+ "--env must be production or preview."
3118
+ );
3119
+ const client = await context.client();
3120
+ const projectId = await context.resolveProjectId();
3121
+ const rows = await client.env.list(projectId, { reveal: true });
3122
+ const selected = rows.filter(
3123
+ (row) => row.target === "all" || row.target === surface
3124
+ );
3125
+ const vars = Object.fromEntries(
3126
+ selected.map((row) => [row.key, row.value ?? ""])
3127
+ );
3128
+ const path = join4(context.cwd, file);
3129
+ if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
3130
+ const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
3131
+ if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
3132
+ }
3133
+ writeFileSync4(
3134
+ path,
3135
+ `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
3136
+ ${serializeDotenv(vars)}`,
3137
+ { mode: 384 }
3138
+ );
3139
+ context.output.result(
3140
+ { projectId, file, environment: surface, count: selected.length },
3141
+ [`\u2705 Pulled ${String(selected.length)} variable(s) into ${file}`]
3142
+ );
3143
+ }
1559
3144
 
1560
3145
  // src/commands/gen.ts
3146
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
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.";
1561
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);
1562
3153
  const client = await context.client();
1563
3154
  const projectId = await context.resolveProjectId();
1564
3155
  const tables = await client.db.tables(projectId);
1565
- 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
+ );
1566
3161
  const undescribed = tables.flatMap(
1567
3162
  (table) => table.error === void 0 ? [] : [{ name: table.name, error: table.error }]
1568
3163
  );
@@ -1570,11 +3165,18 @@ async function runGenTypes(context) {
1570
3165
  context.output.warn(
1571
3166
  `\u26A0 ${printable(name)} could not be described and was generated as an empty interface: ${printable(error)}`
1572
3167
  );
1573
- 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
+ };
1574
3176
  if (context.flags.output) {
1575
- const path = resolve2(context.cwd, context.flags.output);
1576
- mkdirSync2(dirname(path), { recursive: true });
1577
- writeFileSync3(path, types);
3177
+ const path = resolve3(context.cwd, context.flags.output);
3178
+ mkdirSync4(dirname2(path), { recursive: true });
3179
+ writeFileSync5(path, types);
1578
3180
  context.output.result(
1579
3181
  {
1580
3182
  projectId,
@@ -1596,7 +3198,7 @@ async function runGenTypes(context) {
1596
3198
  // src/commands/logs.ts
1597
3199
  var FOLLOW_INTERVAL_MS = 3e3;
1598
3200
  function sleep(ms) {
1599
- return new Promise((resolve3) => setTimeout(resolve3, ms));
3201
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
1600
3202
  }
1601
3203
  function formatBuildLog(log) {
1602
3204
  return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
@@ -1720,6 +3322,666 @@ async function runLogs(context) {
1720
3322
  await buildLogs(context, client, target);
1721
3323
  }
1722
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
+
1723
3985
  // src/commands/projects.ts
1724
3986
  function productionUrl(subdomain) {
1725
3987
  return `https://${subdomain}.${new URL(controlPlaneUrl()).hostname}`;
@@ -1882,12 +4144,76 @@ Usage:
1882
4144
 
1883
4145
  naro db enable Create the project's database (Cloudflare D1)
1884
4146
  naro db info Database status, size, table count and limits
4147
+ and today's writes against the daily write limit (UTC)
1885
4148
  naro db query <sql> | --file <f> Run SQL against it (reads stdin when piped)
1886
4149
  naro db push [--dry-run] Apply pending migrations from ./migrations (asks first; --yes to skip)
4150
+ naro db dump [--output <f>] Download a SQL dump of the database (stdout by default)
4151
+ [--schema-only|--data-only] [--table a,b] Cloudflare holds other queries until it finishes
4152
+ naro db restore --bookmark <id> Roll the database back (Time Travel, last 30 days; asks first)
4153
+ | --timestamp <time> [--dry-run] prints the command that undoes it
4154
+ naro db pull [--dir <d>] Write the remote schema as migrations/0001_remote_schema.sql
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)
1887
4175
  naro migration new <name> Create migrations/NNNN_<name>.sql (stdin becomes the body when piped)
1888
4176
  naro migration list Local vs applied migrations
1889
4177
  naro migration up [--dry-run] Same as db push
1890
- 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/sdk (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)
1891
4217
 
1892
4218
  naro promote <deployment-id> Publish a build to production
1893
4219
  naro rollback [deployment-id] Roll production back to the previous build
@@ -1898,21 +4224,101 @@ Global options:
1898
4224
  --token <t> Access token (or set NARO_TOKEN)
1899
4225
  --project <id> Target project (overrides .naro/project.json)
1900
4226
  --root <dir> App directory inside the repo (monorepos), e.g. apps/web
1901
- --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)
1902
4228
  --team <slug> Team scope for project listing/linking
1903
4229
  --cwd <dir> Run as if started in <dir>
1904
4230
  --prod Deploy/target production
1905
4231
  --wait Poll until the domain goes live (naro domains verify)
1906
4232
  --no-www Register the apex alone (naro domains add)
1907
4233
  --file <path> SQL file to run (naro db query)
1908
- --dry-run List pending migrations without applying (naro db push)
1909
- --output <file> Write to a file instead of stdout (naro gen types)
4234
+ --dry-run Show what would change without changing it (naro db push, db restore, db reset, db table, db index)
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)
1910
4237
  --dir <path> Migrations directory (default ./migrations, or <--root>/migrations)
4238
+ --strict Fail on warnings too, not only errors (naro db lint)
4239
+ --schema-only Dump the schema without rows (naro db dump)
4240
+ --data-only Dump rows without the schema (naro db dump)
4241
+ --table <a,b> Dump only these tables, comma-separated (naro db dump)
4242
+ --bookmark <id> Restore point: a bookmark from db dump or db restore (naro db restore)
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)
1911
4263
  --debug Verbose diagnostics
1912
4264
 
1913
4265
  Credentials: \`naro login\` opens a browser (GitHub). CI: NARO_TOKEN=$(naro token).
1914
4266
  Set NARO_CONTROL_PLANE_URL to use a custom control plane (default https://naro.sh).
1915
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
+ }
1916
4322
  function route(parsed) {
1917
4323
  const { command, positionals } = parsed;
1918
4324
  const sub = positionals[0];
@@ -2000,6 +4406,28 @@ function route(parsed) {
2000
4406
  return { handler: runDbQuery, positionals: positionals.slice(1) };
2001
4407
  case "push":
2002
4408
  return { handler: runMigrationUp, positionals: positionals.slice(1) };
4409
+ case "dump":
4410
+ return { handler: runDbDump, positionals: positionals.slice(1) };
4411
+ case "restore":
4412
+ return { handler: runDbRestore, positionals: positionals.slice(1) };
4413
+ case "pull":
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
+ }
2003
4431
  default:
2004
4432
  return null;
2005
4433
  }
@@ -2019,6 +4447,60 @@ function route(parsed) {
2019
4447
  };
2020
4448
  case "up":
2021
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
+ };
2022
4504
  default:
2023
4505
  return null;
2024
4506
  }
@@ -2044,8 +4526,8 @@ function route(parsed) {
2044
4526
  function version() {
2045
4527
  try {
2046
4528
  const pkg = JSON.parse(
2047
- readFileSync3(
2048
- join3(dirname2(fileURLToPath(import.meta.url)), "..", "package.json"),
4529
+ readFileSync4(
4530
+ join5(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
2049
4531
  "utf8"
2050
4532
  )
2051
4533
  );
@@ -2081,7 +4563,7 @@ async function main(argv = process.argv.slice(2)) {
2081
4563
  output.fail(
2082
4564
  new NaroError(
2083
4565
  "VALIDATION_FAILED",
2084
- `Unknown option: ${parsed.unknown.join(", ")}. See \`naro help\`.`
4566
+ secretFlagProblem(parsed.unknown) ?? `Unknown option: ${parsed.unknown.join(", ")}. See \`naro help\`.`
2085
4567
  )
2086
4568
  );
2087
4569
  return;