naro-cli 0.10.0 → 0.12.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
@@ -2,14 +2,20 @@
2
2
  import {
3
3
  BACKEND_STEPS,
4
4
  BOOKMARK_FORMAT_MESSAGE,
5
+ INDEX_SQL,
6
+ NAMED_POLICY_SQL,
7
+ NAMED_POLICY_TABLE,
5
8
  NaroClient,
6
9
  NaroError,
7
10
  POLICY_ACTIONS,
8
11
  POLICY_RULES,
12
+ RESET_BACKEND_OUTDATED,
9
13
  RESTORE_OUTCOME_UNKNOWN_CODES,
14
+ SCHEMA_SQL,
10
15
  buildResetPlan,
11
16
  cellText,
12
17
  clearSession,
18
+ commentText,
13
19
  compareMigrations,
14
20
  controlPlaneUrl,
15
21
  countLintedTables,
@@ -19,6 +25,7 @@ import {
19
25
  ensureGitignoreHasNaro,
20
26
  formatRedirectEntry,
21
27
  generateTypes,
28
+ indexesFromRows,
22
29
  isBookmark,
23
30
  isExpired,
24
31
  isNaroError,
@@ -26,6 +33,7 @@ import {
26
33
  isTerminalStatus,
27
34
  lintSchema,
28
35
  listMigrationFiles,
36
+ loadPolicy,
29
37
  loadStoredSession,
30
38
  login,
31
39
  migrationNameMismatch,
@@ -34,26 +42,40 @@ import {
34
42
  openBrowser,
35
43
  parseGitHubRemote,
36
44
  parsePolicyNode,
45
+ parsePolicyStatement,
37
46
  parseRedirectEntry,
38
47
  pendingMigrationNames,
48
+ planPolicyMigration,
39
49
  policiesForTypes,
50
+ policiesLostByReset,
40
51
  projectPath,
41
52
  promptForMissingEnv,
53
+ quoteIdent,
54
+ quotePolicyName,
42
55
  readProjectLink,
43
56
  refreshStoredSession,
44
57
  removeProjectLink,
45
58
  renderRows,
59
+ resetFileRefusedMessage,
60
+ resetPoliciesLostMessage,
61
+ resetPoliciesWithTablesMessage,
46
62
  resolveAuth,
47
63
  resolveMigrationsDir,
48
64
  saveSession,
49
65
  schemaMigrationTitle,
50
66
  serializeDotenv,
51
67
  slugify,
68
+ snapshotFromRows,
69
+ splitPolicyStatements,
70
+ splitStatements,
71
+ storedPolicySql,
52
72
  summarizeStatement,
53
73
  systemTablePredicate,
74
+ tableNames,
54
75
  toNaroError,
76
+ unreadablePolicyComment,
55
77
  writeProjectLink
56
- } from "./chunk-5U6YPVZS.js";
78
+ } from "./chunk-GFCG5C7D.js";
57
79
  import "./chunk-K3NQKI34.js";
58
80
 
59
81
  // src/args.ts
@@ -83,6 +105,7 @@ var BOOLEAN_FLAGS = {
83
105
  "--unique": "unique",
84
106
  "--no-write": "noWrite",
85
107
  "--signed-in": "signedIn",
108
+ "--allow-unindexed": "allowUnindexed",
86
109
  "--api-key-stdin": "apiKeyStdin",
87
110
  "--client-secret-stdin": "clientSecretStdin",
88
111
  "--password-stdin": "passwordStdin"
@@ -444,8 +467,8 @@ function createContext(flags, positionals = []) {
444
467
  }
445
468
 
446
469
  // src/router.ts
447
- import { readFileSync as readFileSync4 } from "fs";
448
- import { dirname as dirname3, join as join5 } from "path";
470
+ import { readFileSync as readFileSync5 } from "fs";
471
+ import { dirname as dirname3, join as join6 } from "path";
449
472
  import { fileURLToPath } from "url";
450
473
 
451
474
  // src/commands/auth.ts
@@ -577,6 +600,25 @@ function printable(text) {
577
600
  }
578
601
  return out;
579
602
  }
603
+ function isPrintable(text) {
604
+ return printable(text) === text;
605
+ }
606
+ var UNSHOWN_WARNING_LINE = "(a line of this warning has characters the terminal cannot show; run with --json to see it exactly)";
607
+ function warningLines(message) {
608
+ return message.split("\n").map(
609
+ (line, index) => index === 0 ? `\u26A0 ${printable(line)}` : ` ${isPrintable(line) ? line : UNSHOWN_WARNING_LINE}`
610
+ );
611
+ }
612
+ function policyProblems(dir, names) {
613
+ return names.flatMap((name) => {
614
+ const split = splitStatements(readFileSync(join(dir, name), "utf8"));
615
+ if (!split.ok) return [];
616
+ const planned = planPolicyMigration(
617
+ split.statements.map((statement) => statement.text)
618
+ );
619
+ return planned.ok ? [] : [{ migration: name, message: planned.message }];
620
+ });
621
+ }
580
622
  function warningLine(warning) {
581
623
  if (warning === "out-of-order")
582
624
  return " \u26A0 older than an applied migration \u2014 db push will still apply it";
@@ -627,23 +669,42 @@ async function runMigrationList(context) {
627
669
  }
628
670
  async function applyMigrationFiles(context, client, projectId, dir, names) {
629
671
  const applied = [];
672
+ const warnings = [];
630
673
  for (const name of names) {
631
674
  const sql = readFileSync(join(dir, name), "utf8");
675
+ let answer;
632
676
  try {
633
- await client.db.applyMigration(projectId, { name, sql });
677
+ answer = await client.db.applyMigration(projectId, { name, sql });
634
678
  } catch (error) {
635
679
  if (!isNaroError(error)) throw error;
680
+ for (const message of serverWarnings(error.details))
681
+ for (const line of warningLines(message)) context.output.warn(line);
636
682
  const shown = context.output.json ? name : printable(name);
637
683
  throw new NaroError(error.code, `${shown}: ${error.message}`, {
638
684
  ...error.status === void 0 ? {} : { status: error.status },
639
- details: { ...error.details, migration: name, applied },
685
+ details: {
686
+ ...error.details,
687
+ migration: name,
688
+ applied,
689
+ appliedWarnings: warnings
690
+ },
640
691
  cause: error
641
692
  });
642
693
  }
643
694
  context.output.log(`\u2713 ${printable(name)}`);
644
695
  applied.push(name);
696
+ for (const message of answer.warnings) {
697
+ warnings.push({ migration: name, message });
698
+ for (const line of warningLines(message)) context.output.warn(line);
699
+ }
645
700
  }
646
- return applied;
701
+ return { applied, warnings };
702
+ }
703
+ function serverWarnings(details) {
704
+ const warnings = details?.warnings;
705
+ return Array.isArray(warnings) ? warnings.filter(
706
+ (warning) => typeof warning === "string"
707
+ ) : [];
647
708
  }
648
709
  var MIGRATION_NAME_RE = /^[A-Za-z0-9_.-]{1,200}\.sql$/;
649
710
  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)";
@@ -715,10 +776,15 @@ async function runMigrationUp(context) {
715
776
  return;
716
777
  }
717
778
  if (context.flags.dryRun) {
718
- context.output.result({ projectId, dryRun: true, pending }, [
779
+ const problems = policyProblems(dir, pending);
780
+ context.output.result({ projectId, dryRun: true, pending, problems }, [
719
781
  `Pending migrations (${String(pending.length)}):`,
720
- ...pending.map((name) => ` ${printable(name)}`)
782
+ ...pending.map((name) => ` ${printable(name)}`),
783
+ ...problems.map(
784
+ (problem) => `${printable(problem.migration)}: ${printable(problem.message)}`
785
+ )
721
786
  ]);
787
+ if (problems.length > 0) process.exitCode = 1;
722
788
  return;
723
789
  }
724
790
  context.output.log(`Pending migrations (${String(pending.length)}):`);
@@ -727,14 +793,14 @@ async function runMigrationUp(context) {
727
793
  context,
728
794
  `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
729
795
  );
730
- const applied = await applyMigrationFiles(
796
+ const { applied, warnings } = await applyMigrationFiles(
731
797
  context,
732
798
  client,
733
799
  projectId,
734
800
  dir,
735
801
  pending
736
802
  );
737
- context.output.result({ projectId, applied, pending: [] }, [
803
+ context.output.result({ projectId, applied, pending: [], warnings }, [
738
804
  `\u2705 Applied ${String(applied.length)} migration(s).`
739
805
  ]);
740
806
  }
@@ -1649,6 +1715,8 @@ async function runDbQuery(context, deps = processStdin4) {
1649
1715
  });
1650
1716
  if (result.truncated) lines.push(ROW_CAP_NOTICE);
1651
1717
  context.output.result({ projectId, result }, lines);
1718
+ for (const warning of result.warnings ?? [])
1719
+ for (const line of warningLines(warning)) context.output.warn(line);
1652
1720
  }
1653
1721
 
1654
1722
  // src/commands/db-backup.ts
@@ -1989,6 +2057,77 @@ var DIAGNOSTICS_SQL = [
1989
2057
  "SELECT * FROM pragma_foreign_key_check;"
1990
2058
  ].join("\n");
1991
2059
  var TRUNCATED_NOTICE = "\u26A0 The diagnostics were cut at 10,000 rows \u2014 there may be more broken rows than these.";
2060
+ var POLICIES_TRUNCATED_NOTICE = "\u26A0 The policies were cut at 10,000 rows, so they were not checked.";
2061
+ var POLICY_TABLE_PROBE = `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '${NAMED_POLICY_TABLE}'`;
2062
+ var POLICY_READ_SQL = [NAMED_POLICY_SQL.list, INDEX_SQL, ...SCHEMA_SQL].map((sql) => `${sql};`).join("\n");
2063
+ function objectsOf(statement) {
2064
+ return statement.rows.map(
2065
+ (row) => Object.fromEntries(
2066
+ statement.columns.map((column, index) => [column, row[index]])
2067
+ )
2068
+ );
2069
+ }
2070
+ async function readPolicies(client, projectId, warn) {
2071
+ let backend;
2072
+ try {
2073
+ backend = await client.backend.info(projectId);
2074
+ } catch (error) {
2075
+ if (error instanceof NaroError && error.code === "BACKEND_DISABLED")
2076
+ return void 0;
2077
+ throw error;
2078
+ }
2079
+ if (backend.status === "none") return void 0;
2080
+ const probe = await client.db.query(projectId, {
2081
+ sql: POLICY_TABLE_PROBE,
2082
+ readOnly: true
2083
+ });
2084
+ if ((probe.statements[0]?.rows.length ?? 0) === 0) return void 0;
2085
+ const answer = await client.db.query(projectId, { sql: POLICY_READ_SQL });
2086
+ const [stored, indexes, columns, definitions] = answer.statements;
2087
+ if (stored === void 0 || indexes === void 0 || columns === void 0 || definitions === void 0)
2088
+ throw new NaroError(
2089
+ "DATABASE_QUERY_FAILED",
2090
+ `The policies could not be read: the database answered with ${String(answer.statements.length)} result sets instead of 4.`
2091
+ );
2092
+ if (answer.truncated) {
2093
+ warn(POLICIES_TRUNCATED_NOTICE);
2094
+ return void 0;
2095
+ }
2096
+ const policies = [];
2097
+ const unreadable = [];
2098
+ for (const row of objectsOf(stored)) {
2099
+ const policy = loadPolicy(row);
2100
+ if (policy === void 0) continue;
2101
+ if (policy.invalid !== null)
2102
+ unreadable.push({
2103
+ table: policy.table,
2104
+ name: policy.name,
2105
+ reason: policy.invalid
2106
+ });
2107
+ else
2108
+ policies.push({
2109
+ table: policy.table,
2110
+ name: policy.name,
2111
+ permissive: policy.permissive,
2112
+ command: policy.command,
2113
+ roles: policy.roles,
2114
+ using: policy.using,
2115
+ check: policy.check,
2116
+ owner: policy.owner
2117
+ });
2118
+ }
2119
+ const snapshot = snapshotFromRows([
2120
+ ...objectsOf(columns),
2121
+ ...objectsOf(definitions)
2122
+ ]);
2123
+ return {
2124
+ tables: tableNames(snapshot),
2125
+ policies,
2126
+ unreadable,
2127
+ snapshot,
2128
+ indexes: indexesFromRows(objectsOf(indexes))
2129
+ };
2130
+ }
1992
2131
  function firstCell(row) {
1993
2132
  const value = row[0];
1994
2133
  return value === null || value === void 0 ? "" : String(value);
@@ -2034,12 +2173,18 @@ async function runDbLint(context) {
2034
2173
  rowid: rowidOf(cellText(broken, row, "rowid")),
2035
2174
  parent: cellText(broken, row, "parent")
2036
2175
  }));
2176
+ const policies = await readPolicies(
2177
+ client,
2178
+ projectId,
2179
+ (message) => context.output.warn(message)
2180
+ );
2037
2181
  const input = {
2038
2182
  tables: tables.filter((table) => !shadow.has(table.name)),
2039
2183
  foreignKeyCheck,
2040
2184
  quickCheck: quick.rows.map(firstCell),
2041
2185
  triggers: triggers.rows.map((row) => cellText(triggers, row, "name")),
2042
- virtualTables
2186
+ virtualTables,
2187
+ ...policies === void 0 ? {} : { policies }
2043
2188
  };
2044
2189
  const issues = lintSchema(input);
2045
2190
  const counted = countLintedTables(input);
@@ -2055,12 +2200,14 @@ async function runDbLint(context) {
2055
2200
  }
2056
2201
 
2057
2202
  // src/commands/db-reset.ts
2203
+ import { readFileSync as readFileSync3 } from "fs";
2204
+ import { join as join3 } from "path";
2058
2205
  var MAX_STATEMENTS = 50;
2059
2206
  var SCHEMA_FILTER = `${systemTablePredicate("name")} AND ${systemTablePredicate("tbl_name")}`;
2060
- var SCHEMA_SQL = [
2207
+ var SCHEMA_SQL2 = [
2061
2208
  `SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view', 'trigger') AND ${SCHEMA_FILTER} ORDER BY name;`,
2062
2209
  "SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'virtual';",
2063
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations');"
2210
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations', '${NAMED_POLICY_TABLE}');`
2064
2211
  ].join("\n");
2065
2212
  function literal(value) {
2066
2213
  return `'${value.replace(/'/g, "''")}'`;
@@ -2072,7 +2219,7 @@ function chunk(items2, size) {
2072
2219
  return pieces;
2073
2220
  }
2074
2221
  async function readSchema(client, projectId) {
2075
- const schema = await client.db.query(projectId, { sql: SCHEMA_SQL });
2222
+ const schema = await client.db.query(projectId, { sql: SCHEMA_SQL2 });
2076
2223
  const [listed, virtual, internal] = schema.statements;
2077
2224
  if (listed === void 0 || virtual === void 0 || internal === void 0)
2078
2225
  throw new NaroError(
@@ -2118,7 +2265,54 @@ async function readSchema(client, projectId) {
2118
2265
  internal: {
2119
2266
  sqliteSequence: internalNames.has("sqlite_sequence"),
2120
2267
  ledger: internalNames.has("d1_migrations")
2121
- }
2268
+ },
2269
+ policyTable: internalNames.has(NAMED_POLICY_TABLE)
2270
+ };
2271
+ }
2272
+ async function hasBackend(client, projectId) {
2273
+ try {
2274
+ return (await client.backend.info(projectId)).status !== "none";
2275
+ } catch (error) {
2276
+ if (isNaroError(error) && error.code === "BACKEND_DISABLED") return false;
2277
+ throw error;
2278
+ }
2279
+ }
2280
+ async function policyPlan(client, projectId, schema, dir, files) {
2281
+ if (!schema.policyTable) {
2282
+ if (await hasBackend(client, projectId))
2283
+ throw new NaroError("BACKEND_OUTDATED", RESET_BACKEND_OUTDATED);
2284
+ return { clearPolicies: false, lost: [], withTables: [] };
2285
+ }
2286
+ const answer = await client.db.query(projectId, {
2287
+ sql: NAMED_POLICY_SQL.list
2288
+ });
2289
+ const stored = answer.statements[0];
2290
+ if (stored === void 0)
2291
+ throw new NaroError(
2292
+ "DATABASE_QUERY_FAILED",
2293
+ "The policies could not be read: the database answered with no result set."
2294
+ );
2295
+ if (answer.truncated)
2296
+ throw new NaroError(
2297
+ "DATABASE_QUERY_FAILED",
2298
+ "The policies are too many to read in one pass: the list was cut at 10,000 rows, so the reset cannot tell which ones your migration files do not make."
2299
+ );
2300
+ const current = stored.rows.map((row) => ({
2301
+ table: cellText(stored, row, "table_name"),
2302
+ name: cellText(stored, row, "name"),
2303
+ ...storedPolicySql(
2304
+ Object.fromEntries(
2305
+ stored.columns.map((column, index) => [column, row[index]])
2306
+ )
2307
+ )
2308
+ }));
2309
+ return {
2310
+ clearPolicies: true,
2311
+ ...policiesLostByReset({
2312
+ current,
2313
+ tables: schema.tables,
2314
+ files: files.map((name) => readFileSync3(join3(dir, name), "utf8"))
2315
+ })
2122
2316
  };
2123
2317
  }
2124
2318
  function resetStopped(context, error, bookmark, lead, message) {
@@ -2140,16 +2334,72 @@ async function runDbReset(context) {
2140
2334
  const dir = migrationsDir(context);
2141
2335
  const shownDir = relativeToCwd(context, dir);
2142
2336
  const files = listMigrationFiles(dir);
2337
+ const problems = policyProblems(dir, files);
2338
+ const problemLines = problems.map(
2339
+ (problem) => `${context.output.json ? problem.migration : printable(problem.migration)}: ${context.output.json ? problem.message : printable(problem.message)}`
2340
+ );
2341
+ if (problems.length > 0 && !context.flags.dryRun)
2342
+ throw new NaroError(
2343
+ "VALIDATION_FAILED",
2344
+ `The control plane would refuse these migration files, so the reset would stop at the first of them with the schema already dropped. Nothing was changed:
2345
+ ${problemLines.join("\n")}`,
2346
+ { details: { problems } }
2347
+ );
2143
2348
  const client = await context.client();
2144
2349
  const projectId = await context.resolveProjectId();
2145
2350
  const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
2146
- const plan = buildResetPlan(await readSchema(client, projectId));
2351
+ const { policyTable: policyTable2, ...input } = await readSchema(client, projectId);
2352
+ const plan = buildResetPlan(input, { clearPolicies: policyTable2 });
2147
2353
  const { tables, views, triggers } = plan.dropped;
2148
2354
  const counts = `${String(tables.length)} table(s), ${String(views.length)} view(s) and ${String(triggers.length)} trigger(s)`;
2149
2355
  const dropped = tables.length + views.length + triggers.length;
2356
+ const policies = await policyPlan(
2357
+ client,
2358
+ projectId,
2359
+ { policyTable: policyTable2, tables },
2360
+ dir,
2361
+ files
2362
+ );
2363
+ const policiesNotInFiles = policies.lost.map((policy) => ({
2364
+ name: `${policy.table}.${policy.name}`,
2365
+ sql: policy.sql
2366
+ }));
2367
+ const policiesOfTablesNotInFiles = policies.withTables.map(
2368
+ (policy) => `${policy.table}.${policy.name}`
2369
+ );
2370
+ const refused = policies.refused && {
2371
+ migration: files[policies.refused.file] ?? "",
2372
+ message: policies.refused.reason
2373
+ };
2374
+ if (refused && !problems.some((one) => one.migration === refused.migration))
2375
+ context.output.warn(
2376
+ `\u26A0 ${printable(resetFileRefusedMessage(refused.migration, refused.message))}`
2377
+ );
2378
+ if (policies.lost.length > 0) {
2379
+ context.output.warn(
2380
+ `\u26A0 ${printable(resetPoliciesLostMessage(policies.lost))}`
2381
+ );
2382
+ for (const policy of policies.lost)
2383
+ for (const line of policy.statements.every(isPrintable) ? policy.statements : [UNSHOWN_WARNING_LINE])
2384
+ context.output.warn(` ${line}`);
2385
+ }
2386
+ if (policies.withTables.length > 0)
2387
+ context.output.warn(
2388
+ `\u26A0 ${printable(resetPoliciesWithTablesMessage(policies.withTables))}`
2389
+ );
2150
2390
  if (context.flags.dryRun) {
2151
2391
  context.output.result(
2152
- { dryRun: true, tables, views, triggers, migrations: files },
2392
+ {
2393
+ dryRun: true,
2394
+ tables,
2395
+ views,
2396
+ triggers,
2397
+ migrations: files,
2398
+ policiesNotInFiles,
2399
+ policiesOfTablesNotInFiles,
2400
+ problems,
2401
+ ...refused ? { wouldBeRefused: refused } : {}
2402
+ },
2153
2403
  [
2154
2404
  `Would drop ${counts}:`,
2155
2405
  ...views.map((name) => ` ${"view".padEnd(9)}${printable(name)}`),
@@ -2157,9 +2407,11 @@ async function runDbReset(context) {
2157
2407
  ...tables.map((name) => ` ${"table".padEnd(9)}${printable(name)}`),
2158
2408
  `Would re-apply ${String(files.length)} migration file(s) from ${shownDir}:`,
2159
2409
  ...files.map((name) => ` ${printable(name)}`),
2160
- "Nothing was changed."
2410
+ "Nothing was changed.",
2411
+ ...problemLines
2161
2412
  ]
2162
2413
  );
2414
+ if (problems.length > 0 || refused) process.exitCode = 1;
2163
2415
  return;
2164
2416
  }
2165
2417
  if (bookmark !== null)
@@ -2191,15 +2443,16 @@ async function runDbReset(context) {
2191
2443
  `No migration files in ${shownDir} \u2014 the database is now empty.`
2192
2444
  );
2193
2445
  let reapplied = [];
2446
+ let warnings = [];
2194
2447
  try {
2195
2448
  if (files.length > 0)
2196
- reapplied = await applyMigrationFiles(
2449
+ ({ applied: reapplied, warnings } = await applyMigrationFiles(
2197
2450
  context,
2198
2451
  client,
2199
2452
  projectId,
2200
2453
  dir,
2201
2454
  files
2202
- );
2455
+ ));
2203
2456
  } catch (error) {
2204
2457
  throw resetStopped(
2205
2458
  context,
@@ -2209,1996 +2462,2230 @@ async function runDbReset(context) {
2209
2462
  (underlying) => underlying
2210
2463
  );
2211
2464
  }
2212
- context.output.result({ dropped, reapplied: reapplied.length, bookmark }, [
2213
- `\u2713 Reset the project database: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
2214
- ...bookmark === null ? [] : [` To undo: naro db restore --bookmark ${bookmark}`]
2215
- ]);
2465
+ context.output.result(
2466
+ {
2467
+ dropped,
2468
+ reapplied: reapplied.length,
2469
+ bookmark,
2470
+ policiesNotInFiles,
2471
+ policiesOfTablesNotInFiles,
2472
+ warnings
2473
+ },
2474
+ [
2475
+ `\u2713 Reset the project database: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
2476
+ ...bookmark === null ? [] : [` To undo: naro db restore --bookmark ${bookmark}`]
2477
+ ]
2478
+ );
2216
2479
  }
2217
2480
 
2218
2481
  // src/commands/db-schema.ts
2219
2482
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
2220
- import { join as join3 } from "path";
2221
- var USAGE2 = {
2222
- table: "Usage: naro db table create|add-column|rename-column|rename|drop-column|drop \u2026 (see naro help)",
2223
- index: "Usage: naro db index create|drop \u2026 (see naro help)",
2224
- create: "Usage: naro db table create <name> --column <spec> [--column <spec>\u2026] [--owner <column>] [--if-not-exists]",
2225
- addColumn: "Usage: naro db table add-column <table> --column <spec>",
2226
- renameColumn: "Usage: naro db table rename-column <table> <column> <new-name>",
2227
- rename: "Usage: naro db table rename <table> <new-name>",
2228
- dropColumn: "Usage: naro db table drop-column <table> <column>",
2229
- drop: "Usage: naro db table drop <table>",
2230
- indexCreate: "Usage: naro db index create <table> <column[,column\u2026]> [--unique] [--name <name>]",
2231
- indexDrop: "Usage: naro db index drop <name>"
2483
+ import { join as join4 } from "path";
2484
+
2485
+ // src/commands/policy.ts
2486
+ import { readFileSync as readFileSync4 } from "fs";
2487
+ import { resolve as resolve3 } from "path";
2488
+
2489
+ // ../../packages/naro-sdk/src/query/filters.ts
2490
+ var MAX_FILTER_DEPTH = 4;
2491
+ var OPERATORS = {
2492
+ eq: "eq",
2493
+ neq: "neq",
2494
+ gt: "gt",
2495
+ gte: "gte",
2496
+ lt: "lt",
2497
+ lte: "lte",
2498
+ in: "in",
2499
+ is: "is",
2500
+ like: "like",
2501
+ ilike: "ilike",
2502
+ contains: "contains",
2503
+ containedBy: "containedBy",
2504
+ overlaps: "overlaps",
2505
+ cs: "contains",
2506
+ cd: "containedBy",
2507
+ ov: "overlaps"
2232
2508
  };
2233
- var SPEC_USAGE = '--column needs name:type, e.g. --column "title:text:notnull".';
2234
- var ON_DELETE = {
2235
- cascade: "CASCADE",
2236
- "set-null": "SET NULL",
2237
- "set-default": "SET DEFAULT",
2238
- restrict: "RESTRICT",
2239
- "no-action": "NO ACTION"
2509
+ function operatorOf(name) {
2510
+ return typeof name === "string" && Object.hasOwn(OPERATORS, name) ? OPERATORS[name] : void 0;
2511
+ }
2512
+ var Refusal = class extends Error {
2240
2513
  };
2241
- var KEYWORDS = /* @__PURE__ */ new Set(["CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME"]);
2242
- var NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
2243
- function segments(spec) {
2244
- const parts = [];
2245
- let current = "";
2246
- let quoted = false;
2247
- for (const char of spec) {
2248
- if (char === "'") quoted = !quoted;
2249
- if (char === ":" && !quoted) {
2250
- parts.push(current);
2251
- current = "";
2252
- continue;
2253
- }
2254
- current += char;
2255
- }
2256
- if (quoted) return null;
2257
- parts.push(current);
2258
- return parts;
2514
+ function refuse(reason) {
2515
+ throw new Refusal(reason);
2259
2516
  }
2260
- function defaultValue(text) {
2261
- if (text.length >= 2 && text.startsWith("'") && text.endsWith("'"))
2262
- return text.slice(1, -1).replace(/''/g, "'");
2263
- const lower = text.toLowerCase();
2264
- if (lower === "null") return null;
2265
- if (lower === "true") return true;
2266
- if (lower === "false") return false;
2267
- const upper = text.toUpperCase();
2268
- if (KEYWORDS.has(upper)) return { keyword: upper };
2269
- if (NUMBER.test(text)) return Number(text);
2270
- return text;
2517
+ var LIST_HINT = {
2518
+ in: ["(", ")", "needs a list in parentheses, like in.(a,b),"],
2519
+ array: ["{", "}", "needs a list in braces, like cs.{a,b},"]
2520
+ };
2521
+ function isSpace(char) {
2522
+ return char !== "" && char.trim() === "";
2271
2523
  }
2272
- function parseColumnSpec(spec) {
2273
- const shown = `--column "${printable(spec)}"`;
2274
- const parts = segments(spec);
2275
- if (parts === null)
2276
- throw new NaroError(
2277
- "VALIDATION_FAILED",
2278
- `${shown}: a quoted default is not closed.`
2279
- );
2280
- const [name, type, ...modifiers] = parts;
2281
- if (!name || !type) throw new NaroError("VALIDATION_FAILED", SPEC_USAGE);
2282
- const column = {
2283
- name,
2284
- type
2285
- };
2286
- let onDelete;
2287
- for (const modifier of modifiers) {
2288
- const eq = modifier.indexOf("=");
2289
- const key = (eq < 0 ? modifier : modifier.slice(0, eq)).toLowerCase();
2290
- const value = eq < 0 ? void 0 : modifier.slice(eq + 1);
2291
- if (value === void 0 && key === "pk") column.primaryKey = true;
2292
- else if (value === void 0 && key === "autoincrement")
2293
- column.autoIncrement = true;
2294
- else if (value === void 0 && key === "notnull") column.notNull = true;
2295
- else if (value === void 0 && key === "unique") column.unique = true;
2296
- else if (value !== void 0 && key === "default")
2297
- column.default = defaultValue(value);
2298
- else if (value !== void 0 && key === "references") {
2299
- const dot = value.lastIndexOf(".");
2300
- const table = dot < 0 ? "" : value.slice(0, dot);
2301
- const target = dot < 0 ? "" : value.slice(dot + 1);
2302
- if (!table || !target)
2303
- throw new NaroError(
2304
- "VALIDATION_FAILED",
2305
- `${shown}: references needs <table>.<column>.`
2306
- );
2307
- column.references = { table, column: target };
2308
- } else if (value !== void 0 && key === "ondelete") {
2309
- onDelete = ON_DELETE[value.toLowerCase()];
2310
- if (!onDelete)
2311
- throw new NaroError(
2312
- "VALIDATION_FAILED",
2313
- `${shown}: ondelete takes cascade, set-null, set-default, restrict or no-action.`
2314
- );
2315
- } else
2316
- throw new NaroError(
2317
- "VALIDATION_FAILED",
2318
- `${shown}: unknown modifier ${printable(modifier)}. Use pk, autoincrement, notnull, unique, default=<value>, references=<table>.<column> or ondelete=<action>.`
2319
- );
2524
+ function looseWord(text) {
2525
+ return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "");
2526
+ }
2527
+ var Parser = class {
2528
+ constructor(text, tokens = {}) {
2529
+ this.text = text;
2530
+ this.tokens = tokens;
2320
2531
  }
2321
- if (onDelete !== void 0) {
2322
- if (!column.references)
2323
- throw new NaroError(
2324
- "VALIDATION_FAILED",
2325
- `${shown}: ondelete needs references=<table>.<column>.`
2326
- );
2327
- column.references.onDelete = onDelete;
2532
+ index = 0;
2533
+ /**
2534
+ * A token at the next value, followed only by spaces and then one of
2535
+ * `stops` or the end — `auth.uid()x` is text, not the token.
2536
+ */
2537
+ token(stops) {
2538
+ this.skipSpaces();
2539
+ const at = this.index;
2540
+ for (const [word, value] of Object.entries(this.tokens)) {
2541
+ if (!this.text.startsWith(word, at)) continue;
2542
+ let end = at + word.length;
2543
+ while (isSpace(this.text[end] ?? "")) end++;
2544
+ const next = this.text[end] ?? "";
2545
+ if (next !== "" && !stops.includes(next)) continue;
2546
+ this.index = end;
2547
+ return { value, word, at };
2548
+ }
2549
+ return void 0;
2328
2550
  }
2329
- return column;
2330
- }
2331
- function policyLine(policy, dryRun = false) {
2332
- const rules = [policy.select, policy.insert, policy.update, policy.delete];
2333
- const owner = policy.ownerColumn === null ? "" : ` (owner column ${printable(policy.ownerColumn)})`;
2334
- const uniform = rules.every((rule) => rule === rules[0]);
2335
- const summary = uniform ? `${String(rules[0])} for select, insert, update and delete` : `select ${policy.select}, insert ${policy.insert}, update ${policy.update}, delete ${policy.delete}`;
2336
- const head = dryRun ? "Would set the Data API policy" : "\u2713 Data API policy";
2337
- return `${head} on ${printable(policy.table)}: ${summary}${owner}.`;
2338
- }
2339
- function removedLine(policy, dryRun) {
2340
- const owner = policy.ownerColumn === null ? "" : `, owner column ${printable(policy.ownerColumn)}`;
2341
- const rules = `select ${policy.select}, insert ${policy.insert}, update ${policy.update}, delete ${policy.delete}${owner}`;
2342
- return `${dryRun ? "Would remove" : "\u2713 Removed"} the Data API policy on ${printable(policy.table)} (${rules}).`;
2343
- }
2344
- async function runSchemaChange(context, operations, question) {
2345
- const dryRun = context.flags.dryRun === true;
2346
- if (question !== void 0 && !dryRun)
2347
- await confirmOrAbort(context, question);
2348
- const sent = dryRun ? operations.map((operation) => {
2349
- if (operation.op !== "dropTable" && operation.op !== "dropColumn")
2350
- return operation;
2351
- const { confirm: _confirm, ...rest } = operation;
2352
- return rest;
2353
- }) : operations;
2354
- const client = await context.client();
2355
- const projectId = await context.resolveProjectId();
2356
- const dir = migrationsDir(context);
2357
- const local = listMigrationFiles(dir);
2358
- const ledger = await appliedMigrations(client, projectId);
2359
- const pending = pendingMigrationNames(local, ledger);
2360
- if (pending.length > 0 && !dryRun)
2361
- throw migrationsPendingError(pending.map(printable));
2362
- const name = nextMigrationFileName(
2363
- [...local, ...ledger.map((row) => row.name)],
2364
- schemaMigrationTitle(operations[0] ?? {})
2365
- );
2366
- const result = await client.db.schema(projectId, sent, {
2367
- ...dryRun ? { dryRun: true } : {},
2368
- name
2369
- });
2370
- const sqlLines = result.sql.trimEnd().split("\n").map(printable);
2371
- const removed = result.removedPolicies ?? [];
2372
- if (result.dryRun) {
2373
- for (const warning of result.warnings)
2374
- context.output.warn(`\u26A0 ${printable(warning)}`);
2375
- context.output.result({ projectId, ...result, file: null }, [
2376
- ...sqlLines,
2377
- `Dry run \u2014 nothing was applied. It would be recorded as ${printable(result.name)}.`,
2378
- ...result.policies.map((policy) => policyLine(policy, true)),
2379
- ...removed.map((policy) => removedLine(policy, true))
2380
- ]);
2381
- return;
2382
- }
2383
- const allWarnings = result.name === name ? result.warnings : [...result.warnings, migrationNameMismatch(name, result.name)];
2384
- let file = null;
2385
- let fileWarning;
2386
- const relDir = relativeToCwd(context, dir);
2387
- const rel = join3(relDir === "." ? "" : relDir, name);
2388
- if (context.flags.noWrite) {
2389
- 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.`;
2390
- } else {
2391
- try {
2392
- mkdirSync3(dir, { recursive: true });
2393
- writeFileSync3(join3(dir, name), result.sql, { flag: "wx" });
2394
- file = rel;
2395
- } catch (error) {
2396
- const reason = (error instanceof Error ? error.message : String(error)).replace(/[.\s]+$/, "");
2397
- 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.`;
2398
- }
2399
- }
2400
- for (const warning of allWarnings)
2401
- context.output.warn(`\u26A0 ${printable(warning)}`);
2402
- if (fileWarning) context.output.warn(`\u26A0 ${printable(fileWarning)}`);
2403
- context.output.result(
2404
- {
2405
- projectId,
2406
- ...result,
2407
- warnings: allWarnings,
2408
- file,
2409
- ...fileWarning ? { fileWarning } : {}
2410
- },
2411
- [
2412
- ...sqlLines,
2413
- file === null ? `\u2705 Applied ${printable(result.name)}.` : `\u2705 Applied ${printable(result.name)} and wrote ${printable(file)}.`,
2414
- ...result.policies.map((policy) => policyLine(policy)),
2415
- ...removed.map((policy) => removedLine(policy, false))
2416
- ]
2417
- );
2418
- }
2419
- function columnsFlag(context) {
2420
- return context.flags.columns ?? [];
2421
- }
2422
- async function runDbTableUsage() {
2423
- throw new NaroError("VALIDATION_FAILED", USAGE2.table);
2424
- }
2425
- async function runDbIndexUsage() {
2426
- throw new NaroError("VALIDATION_FAILED", USAGE2.index);
2427
- }
2428
- async function runDbTableCreate(context) {
2429
- const [table] = context.positionals;
2430
- const specs = columnsFlag(context);
2431
- if (!table || specs.length === 0)
2432
- throw new NaroError("VALIDATION_FAILED", USAGE2.create);
2433
- const columns = specs.map(parseColumnSpec);
2434
- const owner = context.flags.ownerColumn;
2435
- await runSchemaChange(context, [
2436
- {
2437
- op: "createTable",
2438
- table,
2439
- columns,
2440
- ...context.flags.ifNotExists ? { ifNotExists: true } : {},
2441
- ...owner === void 0 ? {} : { owner: { column: owner } }
2442
- }
2443
- ]);
2444
- }
2445
- async function runDbTableAddColumn(context) {
2446
- const [table] = context.positionals;
2447
- const specs = columnsFlag(context);
2448
- const [spec] = specs;
2449
- if (!table || spec === void 0 || specs.length !== 1)
2450
- throw new NaroError("VALIDATION_FAILED", USAGE2.addColumn);
2451
- await runSchemaChange(context, [
2452
- { op: "addColumn", table, column: parseColumnSpec(spec) }
2453
- ]);
2454
- }
2455
- async function runDbTableRenameColumn(context) {
2456
- const [table, column, to] = context.positionals;
2457
- if (!table || !column || !to)
2458
- throw new NaroError("VALIDATION_FAILED", USAGE2.renameColumn);
2459
- await runSchemaChange(context, [{ op: "renameColumn", table, column, to }]);
2460
- }
2461
- async function runDbTableRename(context) {
2462
- const [table, to] = context.positionals;
2463
- if (!table || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
2464
- await runSchemaChange(context, [{ op: "renameTable", table, to }]);
2465
- }
2466
- async function runDbTableDropColumn(context) {
2467
- const [table, column] = context.positionals;
2468
- if (!table || !column)
2469
- throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
2470
- const target = `${table}.${column}`;
2471
- await runSchemaChange(
2472
- context,
2473
- [{ op: "dropColumn", table, column, confirm: target }],
2474
- `Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
2475
- );
2476
- }
2477
- async function runDbTableDrop(context) {
2478
- const [table] = context.positionals;
2479
- if (!table) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
2480
- await runSchemaChange(
2481
- context,
2482
- [{ op: "dropTable", table, confirm: table }],
2483
- `Drop table ${printable(table)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
2484
- );
2485
- }
2486
- async function runDbIndexCreate(context) {
2487
- const [table, list] = context.positionals;
2488
- const columns = listFlag(list);
2489
- if (!table || columns.length === 0)
2490
- throw new NaroError("VALIDATION_FAILED", USAGE2.indexCreate);
2491
- const name = context.flags.name;
2492
- await runSchemaChange(context, [
2493
- {
2494
- op: "createIndex",
2495
- table,
2496
- columns,
2497
- ...context.flags.unique ? { unique: true } : {},
2498
- ...name === void 0 ? {} : { name }
2499
- }
2500
- ]);
2501
- }
2502
- async function runDbIndexDrop(context) {
2503
- const [name] = context.positionals;
2504
- if (!name) throw new NaroError("VALIDATION_FAILED", USAGE2.indexDrop);
2505
- await runSchemaChange(context, [{ op: "dropIndex", name }]);
2506
- }
2507
-
2508
- // src/commands/git.ts
2509
- var defaults2 = {
2510
- detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
2511
- openBrowser,
2512
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
2513
- };
2514
- function connected(context, project) {
2515
- const branch = project.defaultBranch;
2516
- context.output.result(
2517
- {
2518
- projectId: project.id,
2519
- repositoryFullName: project.repositoryFullName,
2520
- autoDeploy: true
2521
- },
2522
- [
2523
- `\u2705 Connected to ${project.repositoryFullName ?? "the repository"}.`,
2524
- ` Pushes to ${branch} now deploy automatically.`,
2525
- "",
2526
- " To turn this off: naro git disconnect"
2527
- ]
2528
- );
2529
- }
2530
- async function runGitConnect(context, overrides = {}) {
2531
- const deps = { ...defaults2, ...overrides };
2532
- const { output } = context;
2533
- const explicit = context.positionals[0];
2534
- let fullName;
2535
- if (explicit) {
2536
- const parsed = parseGitHubRemote(explicit) ?? parseGitHubRemote(`https://github.com/${explicit}`);
2537
- if (!parsed)
2538
- throw new NaroError(
2539
- "VALIDATION_FAILED",
2540
- `"${explicit}" is not an owner/repo pair.`
2541
- );
2542
- fullName = parsed.fullName;
2543
- } else {
2544
- const remote = await deps.detectRemote(context.cwd);
2545
- const parsed = remote ? parseGitHubRemote(remote) : null;
2546
- if (!parsed) {
2547
- throw new NaroError(
2548
- "NO_GIT_REMOTE",
2549
- remote ? `Could not read a GitHub owner/repo from "${remote}". Pass it directly: naro git connect <owner/repo>` : "This directory has no GitHub origin remote. Pass one directly: naro git connect <owner/repo>"
2550
- );
2551
- }
2552
- fullName = parsed.fullName;
2553
- }
2554
- const client = await context.client();
2555
- const projectId = await context.resolveProjectId();
2556
- const first = await client.projects.connectGit(projectId, fullName);
2557
- if (first.ok) {
2558
- connected(context, first.project);
2559
- return;
2560
- }
2561
- if (!first.installUrl) {
2562
- throw new NaroError(
2563
- "REPOSITORY_NOT_ACCESSIBLE",
2564
- `Naro cannot reach ${fullName}, and no install URL was offered. Connect it from the dashboard.`
2565
- );
2551
+ at() {
2552
+ return this.text[this.index] ?? "";
2566
2553
  }
2567
- output.log(`
2568
- \u25B8 Naro needs access to ${fullName}.`);
2569
- output.log(` Opening ${first.installUrl}`);
2570
- deps.openBrowser(first.installUrl);
2571
- output.log(" Waiting for the installation to finish...\n");
2572
- await waitForInstall(context, projectId, fullName, deps);
2573
- }
2574
- async function waitForInstall(context, projectId, fullName, deps) {
2575
- const client = await context.client();
2576
- const project = await client.projects.get(projectId);
2577
- const teams = await client.teams.list();
2578
- const teamSlug = teams.find((team) => team.id === project.teamId)?.slug;
2579
- if (teamSlug) {
2580
- const attempts = deps.pollAttempts ?? 60;
2581
- const interval = deps.pollIntervalMs ?? 3e3;
2582
- for (let attempt2 = 0; attempt2 < attempts; attempt2 += 1) {
2583
- await deps.sleep(interval);
2584
- let reachable = false;
2585
- try {
2586
- const repositories = await client.github.listRepositories(teamSlug);
2587
- reachable = repositories.some(
2588
- (repository) => repository.fullName.toLowerCase() === fullName.toLowerCase()
2554
+ /**
2555
+ * An unquoted value that is one of the tokens misspelled — `auth.uid`,
2556
+ * `AUTH.UID` — is refused: read as text it would compare with the words
2557
+ * themselves (사용자 리뷰 반영). Quoted, it is the text.
2558
+ */
2559
+ notNearToken(value, at) {
2560
+ const loose = looseWord(value);
2561
+ for (const word of Object.keys(this.tokens))
2562
+ if (loose !== "" && loose === looseWord(word))
2563
+ refuse(
2564
+ `has ${JSON.stringify(value)} ${this.where(at)}: did you mean ${word}? Quote it to mean the text`
2589
2565
  );
2590
- } catch {
2591
- }
2592
- if (!reachable) continue;
2593
- const retry = await client.projects.connectGit(projectId, fullName);
2594
- if (retry.ok) {
2595
- connected(context, retry.project);
2596
- return;
2597
- }
2598
- }
2599
2566
  }
2600
- context.output.result(
2601
- { projectId, repositoryFullName: null, autoDeploy: false },
2602
- [
2603
- `\u25B8 Still waiting on access to ${fullName}.`,
2604
- " Finish the installation, then run: naro git connect"
2605
- ]
2606
- );
2607
- }
2608
- async function runGitDisconnect(context) {
2609
- const client = await context.client();
2610
- const projectId = await context.resolveProjectId();
2611
- const project = await client.projects.disconnectGit(projectId);
2612
- context.output.result({ projectId: project.id, autoDeploy: false }, [
2613
- `\u2705 ${project.name} no longer deploys on push.`,
2614
- " Deploy from here with: naro deploy"
2615
- ]);
2616
- }
2617
- async function offerAutoDeploy(context, projectId, overrides) {
2618
- const deps = { ...defaults2, ...overrides };
2619
- if (overrides.canPrompt === false) return;
2620
- try {
2621
- if (readProjectLink(context.cwd)?.autoDeployDeclined === true) return;
2622
- const connection = overrides.gitConnection;
2623
- if (connection.state === "connected" || connection.state === "none") return;
2624
- const fullName = connection.fullName;
2625
- if (!fullName) return;
2626
- let wanted;
2627
- if (connection.state === "unreachable") {
2628
- const installed = connection.installedAccounts ?? [];
2629
- context.output.log(
2630
- `
2631
- \u25B8 Automatic deploys need the Naro GitHub App on \`${connection.repoOwner ?? fullName}\`.`
2632
- );
2633
- if (overrides.teamSlug && installed.length > 0)
2634
- context.output.log(
2635
- ` Team ${overrides.teamSlug} has it on: ${installed.join(", ")}.`
2636
- );
2637
- wanted = await context.prompter.confirm(
2638
- "Install it for this team now? (opens your browser)"
2639
- );
2640
- } else {
2641
- wanted = await context.prompter.confirm(
2642
- "Should your changes deploy automatically from now on?"
2643
- );
2644
- }
2645
- if (!wanted) {
2646
- const link = readProjectLink(context.cwd);
2647
- writeProjectLink(context.cwd, {
2648
- ...link ?? { projectId },
2649
- autoDeployDeclined: true
2650
- });
2651
- context.output.log(
2652
- "\u25B8 Not now. Later, if you change your mind: naro git connect"
2653
- );
2654
- return;
2655
- }
2656
- const client = await context.client();
2657
- const result = await client.projects.connectGit(projectId, fullName);
2658
- if (result.ok) {
2659
- connected(context, result.project);
2660
- return;
2661
- }
2662
- if (!result.installUrl) return;
2663
- context.output.log(`
2664
- \u25B8 Naro needs access to ${fullName}.`);
2665
- context.output.log(` Opening ${result.installUrl}`);
2666
- deps.openBrowser(result.installUrl);
2667
- context.output.log(" Waiting for the installation to finish...\n");
2668
- await waitForInstall(context, projectId, fullName, deps);
2669
- } catch (error) {
2670
- context.output.log(
2671
- `\u25B8 Could not set up automatic deploys (${error instanceof Error ? error.message : String(error)}).`
2672
- );
2673
- context.output.log(" Try later with: naro git connect");
2567
+ /** `scalar`, with an unquoted value held to notNearToken. */
2568
+ bareOrQuoted(stops) {
2569
+ this.skipSpaces();
2570
+ const at = this.index;
2571
+ const quoted = this.at() === '"';
2572
+ const text = this.scalar(stops);
2573
+ if (!quoted) this.notNearToken(text, at);
2574
+ return { text, quoted };
2674
2575
  }
2675
- }
2676
-
2677
- // src/commands/link.ts
2678
- var SWITCH_ACCOUNT = "Sign in as a different account\u2026";
2679
- async function resolveTeam(context, teams, deps = {}) {
2680
- const explicit = context.flags.team ?? process.env.NARO_TEAM;
2681
- if (explicit) {
2682
- const match = teams.find((team) => team.slug === explicit);
2683
- if (!match)
2684
- throw new NaroError(
2685
- "NOT_FOUND",
2686
- `You are not a member of team "${explicit}".`
2687
- );
2688
- return match;
2576
+ where(index = this.index) {
2577
+ return `at character ${String(index + 1)}`;
2689
2578
  }
2690
- const linked = context.projectLink();
2691
- if (linked?.teamSlug) {
2692
- const match = teams.find((team) => team.slug === linked.teamSlug);
2693
- if (match) return match;
2579
+ skipSpaces() {
2580
+ while (isSpace(this.at())) this.index++;
2694
2581
  }
2695
- if (teams.length === 0)
2696
- throw new NaroError("NOT_FOUND", "Your account has no teams yet.");
2697
- const only = teams.length === 1 ? teams[0] : null;
2698
- const deploying = (deps.intent ?? "deploy") === "deploy";
2699
- if (only && !deploying) return only;
2700
- const canPrompt = deps.canPrompt?.() ?? (!context.flags.json && !context.flags.yes && isInteractive());
2701
- if (!canPrompt)
2702
- throw new NaroError(
2703
- "TEAM_REQUIRED",
2704
- `Cannot pick a team without a prompt. Pass --team <slug>, set NARO_TEAM, or run \`naro link\` first.
2705
- Your teams: ${teams.map((team) => team.slug).join(", ")}`
2706
- );
2707
- const account = deps.accountLabel ? await deps.accountLabel() : "this account";
2708
- if (only) {
2709
- const label2 = only.isPersonal ? `${only.name} (personal)` : only.name;
2710
- context.output.log(`
2711
- ${account} \u2192 ${label2}`);
2712
- const ok = await context.prompter.confirm(
2713
- "Deploy to this account and team?"
2714
- );
2715
- if (ok) return only;
2716
- return switchAndRetry(context, deps);
2582
+ /** `item ("," item)*`, up to a `)` or the end, inside `level` groups. */
2583
+ list(level) {
2584
+ const nodes = [];
2585
+ for (; ; ) {
2586
+ nodes.push(this.item(level));
2587
+ this.skipSpaces();
2588
+ const char = this.at();
2589
+ if (char === ",") this.index++;
2590
+ else if (char === ")" || char === "") return nodes;
2591
+ else refuse(`has unexpected text ${this.where()}`);
2592
+ }
2717
2593
  }
2718
- const options = [
2719
- ...teams.map(
2720
- (team) => `${team.name}${team.isPersonal ? " (personal)" : ""}`
2721
- ),
2722
- SWITCH_ACCOUNT
2723
- ];
2724
- const index = await context.prompter.select(
2725
- deploying ? `Deploying as ${account} \u2014 which team owns this project?` : `Signed in as ${account}. Which team?`,
2726
- options
2727
- );
2728
- const picked = teams[index];
2729
- if (picked) return picked;
2730
- return switchAndRetry(context, deps);
2731
- }
2732
- async function switchAndRetry(context, deps) {
2733
- if (!deps.switchAccount || !deps.listTeams)
2734
- throw new NaroError(
2735
- "CANCELED",
2736
- "Canceled. Switch accounts with `naro logout && naro login`."
2737
- );
2738
- await deps.switchAccount();
2739
- const teams = await deps.listTeams();
2740
- return resolveTeam(context, teams, deps);
2741
- }
2742
- function saveLink(context, project, teamSlug) {
2743
- writeProjectLink(context.cwd, {
2744
- projectId: project.id,
2745
- teamSlug,
2746
- projectName: project.name
2747
- });
2748
- if (ensureGitignoreHasNaro(context.cwd))
2749
- context.output.log("\u25B8 added .naro to .gitignore");
2750
- }
2751
- async function runLink(context) {
2752
- const client = await context.client();
2753
- const teams = await client.teams.list();
2754
- const team = await resolveTeam(context, teams, { intent: "read" });
2755
- const projects = await client.projects.list(team.slug);
2756
- let project;
2757
- if (context.flags.project) {
2758
- project = projects.find(
2759
- (candidate) => candidate.id === context.flags.project
2760
- );
2761
- if (!project) {
2762
- project = await client.projects.get(context.flags.project);
2594
+ item(level) {
2595
+ this.skipSpaces();
2596
+ const start = this.index;
2597
+ const char = this.at();
2598
+ if (char === "" || char === "," || char === ")")
2599
+ refuse(`has an empty item ${this.where()}`);
2600
+ const group = /^(not\.)?(and|or)\(/.exec(this.text.slice(start));
2601
+ if (!group) return this.leaf(level);
2602
+ const negated = group[1] !== void 0;
2603
+ const inner = level + (negated ? 2 : 1);
2604
+ if (inner > MAX_FILTER_DEPTH)
2605
+ refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
2606
+ const open = start + group[0].length - 1;
2607
+ this.index = open + 1;
2608
+ const children = this.list(inner);
2609
+ if (this.at() !== ")") refuse(`has an unclosed "(" ${this.where(open)}`);
2610
+ this.index++;
2611
+ const node = group[2] === "or" ? { or: children } : { and: children };
2612
+ return negated ? { not: node } : node;
2613
+ }
2614
+ /** `column.[not.]operator.value`. */
2615
+ leaf(level) {
2616
+ const start = this.index;
2617
+ const column = this.at() === '"' ? this.quoted() : this.word().trim();
2618
+ if (column === "" || this.at() !== ".")
2619
+ refuse(`needs column.operator.value ${this.where(start)}`);
2620
+ this.index++;
2621
+ let nameAt = this.index;
2622
+ let name = this.word();
2623
+ let negated = false;
2624
+ if (name === "not" && this.at() === ".") {
2625
+ if (level + 1 > MAX_FILTER_DEPTH)
2626
+ refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
2627
+ negated = true;
2628
+ this.index++;
2629
+ nameAt = this.index;
2630
+ name = this.word();
2763
2631
  }
2764
- } else if (context.flags.name) {
2765
- project = projects.find(
2766
- (candidate) => candidate.name === context.flags.name || candidate.slug === context.flags.name
2767
- );
2768
- if (!project)
2769
- throw new NaroError(
2770
- "PROJECT_NOT_FOUND",
2771
- `No project named "${context.flags.name}" in team ${team.slug}. Run \`naro deploy\` to create it.`
2772
- );
2773
- } else {
2774
- const guess = slugify(context.cwd.split("/").at(-1) ?? "app");
2775
- const exact = projects.find(
2776
- (candidate) => candidate.name === guess || candidate.slug === guess
2777
- );
2778
- if (exact && (context.flags.yes || context.flags.json)) {
2779
- project = exact;
2780
- } else if (projects.length === 0) {
2781
- throw new NaroError(
2782
- "PROJECT_NOT_FOUND",
2783
- `Team ${team.slug} has no projects yet. Run \`naro deploy\` to create one.`
2784
- );
2785
- } else {
2786
- const options = projects.map(
2787
- (candidate) => `${candidate.name} (${candidate.subdomain})`
2788
- );
2789
- const index = await context.prompter.select(
2790
- `Link ${context.cwd} to which project?`,
2791
- options
2792
- );
2793
- project = projects[index];
2632
+ const operator = operatorOf(name);
2633
+ if (this.at() !== ".") {
2634
+ if (name !== "" && operator === void 0)
2635
+ refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
2636
+ refuse(`needs column.operator.value ${this.where(start)}`);
2794
2637
  }
2638
+ if (operator === void 0)
2639
+ refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
2640
+ this.index++;
2641
+ const leaf = { operator, column, value: this.value(operator) };
2642
+ return negated ? { not: leaf } : leaf;
2795
2643
  }
2796
- if (!project)
2797
- throw new NaroError("PROJECT_NOT_FOUND", "No project selected.");
2798
- const owningTeam = teams.find((candidate) => candidate.id === project.teamId) ?? team;
2799
- saveLink(context, project, owningTeam.slug);
2800
- context.output.result(
2801
- {
2802
- projectId: project.id,
2803
- projectName: project.name,
2804
- teamSlug: owningTeam.slug,
2805
- subdomain: project.subdomain
2806
- },
2807
- [`\u2705 Linked to ${owningTeam.slug}/${project.name} (.naro/project.json)`]
2808
- );
2809
- }
2810
- async function runUnlink(context) {
2811
- const existing = readProjectLink(context.cwd);
2812
- const removed = removeProjectLink(context.cwd);
2813
- context.output.result(
2814
- { removed, ...existing ? { projectId: existing.projectId } : {} },
2815
- [removed ? "\u2705 Unlinked (.naro removed)" : "Nothing to unlink here."]
2816
- );
2817
- }
2818
-
2819
- // src/commands/deploy.ts
2820
- async function runDeploy(context, deps = { deploy: deployDirectory, offerAutoDeploy }) {
2821
- const { flags, output } = context;
2822
- const link = flags.project ? { projectId: flags.project, teamSlug: void 0 } : readProjectLink(context.cwd);
2823
- if (flags.prod && !flags.yes && !flags.json && isInteractive()) {
2824
- const confirmed = await context.prompter.confirm("Deploy to production?");
2825
- if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
2644
+ /** Up to the next `.`, `,`, `(`, `)` or the end. */
2645
+ word() {
2646
+ const start = this.index;
2647
+ while (this.index < this.text.length && !".,()".includes(this.at()))
2648
+ this.index++;
2649
+ return this.text.slice(start, this.index);
2826
2650
  }
2827
- output.log(`
2828
- \u{1F680} Deploying ${context.cwd}
2829
- `);
2830
- const canPrompt = deps.canPrompt?.() ?? (!flags.json && !flags.yes && isInteractive());
2831
- const team = await resolveDestination(context, link, canPrompt);
2832
- const account = await accountLabel(context);
2833
- output.log(` ${account} \u2192 ${team.slug}
2834
- `);
2835
- const result = await deps.deploy(
2836
- context.cwd,
2837
- {
2838
- name: flags.name,
2839
- projectId: link?.projectId,
2840
- teamSlug: team.slug,
2841
- rootDirectory: flags.root ?? null,
2842
- branch: flags.branch,
2843
- production: flags.prod,
2844
- ...canPrompt && {
2845
- onMissingEnv: (keys) => promptForMissingEnv(
2846
- { cwd: context.cwd, prompter: context.prompter, output },
2847
- keys
2848
- )
2849
- },
2850
- token: flags.token,
2851
- allowLogin: !flags.json && !flags.yes
2852
- },
2853
- (message) => output.log(message)
2854
- );
2855
- const existing = readProjectLink(context.cwd);
2856
- writeProjectLink(context.cwd, {
2857
- projectId: result.projectId,
2858
- teamSlug: result.teamSlug,
2859
- projectName: result.projectSlug,
2860
- ...existing?.autoDeployDeclined === true ? { autoDeployDeclined: true } : {}
2861
- });
2862
- if (!link) {
2863
- if (ensureGitignoreHasNaro(context.cwd))
2864
- output.log("\u25B8 added .naro to .gitignore");
2865
- output.log("\u25B8 linked (.naro/project.json)");
2651
+ /** `"…"`, with `\` making the next character literal. */
2652
+ quoted() {
2653
+ const start = this.index;
2654
+ this.index++;
2655
+ let text = "";
2656
+ for (; ; ) {
2657
+ if (this.index >= this.text.length)
2658
+ refuse(`has an unclosed quote ${this.where(start)}`);
2659
+ const char = this.at();
2660
+ this.index++;
2661
+ if (char === '"') return text;
2662
+ if (char === "\\" && this.index < this.text.length) {
2663
+ text += this.at();
2664
+ this.index++;
2665
+ } else text += char;
2666
+ }
2667
+ }
2668
+ /** A quoted value, or everything up to `stops`, trimmed. */
2669
+ scalar(stops) {
2670
+ this.skipSpaces();
2671
+ if (this.at() === '"') {
2672
+ const text = this.quoted();
2673
+ this.skipSpaces();
2674
+ if (this.at() !== "" && !stops.includes(this.at()))
2675
+ refuse(`has text after a closing quote ${this.where()}`);
2676
+ return text;
2677
+ }
2678
+ const start = this.index;
2679
+ while (this.index < this.text.length && !stops.includes(this.at()))
2680
+ this.index++;
2681
+ return this.text.slice(start, this.index).trim();
2682
+ }
2683
+ /**
2684
+ * `(a,b)` for `in`: text values, except that an unquoted `true` or `false`
2685
+ * is a boolean (사용자 리뷰 반영, as in `eq`). `{a,b}` for the array
2686
+ * operators: text, except that an unquoted exact decimal is a number
2687
+ * (적대적 리뷰 반영, narrowed by 사용자 리뷰 반영) — json_each's values
2688
+ * have no type affinity to turn the text "1" into 1, so as text `cs.{1}`
2689
+ * matched no numeric array and `not.ov.{1}` matched every row. Quoted,
2690
+ * `"1"` and `"true"` stay text.
2691
+ */
2692
+ listValue(kind) {
2693
+ const [open, close, hint] = LIST_HINT[kind];
2694
+ this.skipSpaces();
2695
+ const openAt = this.index;
2696
+ if (this.at() !== open) refuse(`${hint} ${this.where()}`);
2697
+ this.index++;
2698
+ this.skipSpaces();
2699
+ const values = [];
2700
+ if (this.at() === close) {
2701
+ this.index++;
2702
+ return values;
2703
+ }
2704
+ for (; ; ) {
2705
+ this.skipSpaces();
2706
+ const itemAt = this.index;
2707
+ const token = this.token(`,${close}`);
2708
+ if (token) values.push(token.value);
2709
+ else {
2710
+ const { text: value, quoted } = this.bareOrQuoted(`,${close}`);
2711
+ if (!quoted && value === "") {
2712
+ if (this.index >= this.text.length)
2713
+ refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
2714
+ refuse(`has an empty item ${this.where(itemAt)}`);
2715
+ }
2716
+ values.push(
2717
+ quoted ? value : kind === "array" ? exactNumber(value) : truth(value)
2718
+ );
2719
+ }
2720
+ if (this.index >= this.text.length)
2721
+ refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
2722
+ const char = this.at();
2723
+ this.index++;
2724
+ if (char === close) return values;
2725
+ }
2866
2726
  }
2867
- await deps.offerAutoDeploy?.(context, result.projectId, {
2868
- canPrompt,
2869
- gitConnection: result.gitConnection,
2870
- teamSlug: result.teamSlug
2871
- });
2872
- const inspect = `${controlPlaneUrl()}${projectPath(result.teamSlug, result.projectSlug)}`;
2873
- context.output.result(
2874
- {
2875
- deploymentId: result.deploymentId,
2876
- projectId: result.projectId,
2877
- status: result.status,
2878
- url: result.url,
2879
- target: result.production ? "production" : "preview",
2880
- teamSlug: result.teamSlug,
2881
- inspectUrl: inspect,
2882
- missingEnvKeys: result.missingEnvKeys
2883
- },
2884
- [
2885
- "",
2886
- `\u2705 Deployment ready (${result.production ? "production" : "preview"})`,
2887
- "",
2888
- ` ${result.url}`,
2889
- ` Inspect ${inspect}`
2890
- ]
2891
- );
2892
- }
2893
- async function resolveDestination(context, link, canPrompt) {
2894
- const client = await context.client();
2895
- const teams = await client.teams.list();
2896
- const deps = scopeDeps(context, canPrompt);
2897
- if (link?.teamSlug) {
2898
- const match = teams.find((team) => team.slug === link.teamSlug);
2899
- if (match) return match;
2900
- } else if (link) {
2901
- let project;
2902
- try {
2903
- project = await client.projects.get(link.projectId);
2904
- } catch (error) {
2905
- if (isNaroError(error) && error.code !== "NOT_FOUND") throw error;
2906
- throw new NaroError(
2907
- "PROJECT_NOT_FOUND",
2908
- `This directory is linked to a project ${await accountLabel(context)} cannot see.
2909
- The CLI signs in separately from the dashboard \u2014 check with \`naro whoami\`.
2910
- Wrong account? naro logout && naro login
2911
- Project gone? remove .naro, then naro deploy`,
2912
- { cause: error }
2913
- );
2727
+ /** The value after `operator.`. */
2728
+ value(operator) {
2729
+ const start = this.index;
2730
+ switch (operator) {
2731
+ case "in":
2732
+ return this.listValue("in");
2733
+ case "contains":
2734
+ case "containedBy":
2735
+ case "overlaps":
2736
+ return this.listValue("array");
2737
+ case "is":
2738
+ return keyword(this.scalar(",)"), start);
2739
+ case "like":
2740
+ case "ilike": {
2741
+ const token = this.token(",)");
2742
+ if (token)
2743
+ refuse(
2744
+ `cannot use ${token.word} with ${operator} ${this.where(token.at)}`
2745
+ );
2746
+ return likePattern(this.bareOrQuoted(",)").text);
2747
+ }
2748
+ case "eq":
2749
+ case "neq": {
2750
+ const token = this.token(",)");
2751
+ if (token) return token.value;
2752
+ const { text, quoted } = this.bareOrQuoted(",)");
2753
+ return quoted ? text : truth(text);
2754
+ }
2755
+ default: {
2756
+ const token = this.token(",)");
2757
+ return token ? token.value : this.bareOrQuoted(",)").text;
2758
+ }
2914
2759
  }
2915
- const match = teams.find((team) => team.id === project.teamId);
2916
- if (match) return match;
2917
2760
  }
2918
- return resolveTeam(context, teams, deps);
2761
+ };
2762
+ function likePattern(pattern) {
2763
+ return pattern.replaceAll("*", "%");
2919
2764
  }
2920
- function scopeDeps(context, canPrompt) {
2921
- return {
2922
- canPrompt: () => canPrompt,
2923
- accountLabel: () => accountLabel(context),
2924
- switchAccount: async () => {
2925
- clearSession();
2926
- await runLogin(context);
2927
- },
2928
- listTeams: async () => (await context.client()).teams.list()
2929
- };
2765
+ function truth(text) {
2766
+ const word = text.toLowerCase();
2767
+ if (word === "true") return true;
2768
+ if (word === "false") return false;
2769
+ return text;
2930
2770
  }
2931
- async function accountLabel(context) {
2932
- try {
2933
- const { user } = await context.auth();
2934
- if (!user) return "this account";
2935
- const name = user.email ?? user.id;
2936
- return user.github ? `${name} (${user.github})` : name;
2937
- } catch {
2938
- return "this account";
2939
- }
2771
+ var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
2772
+ function exactNumber(text) {
2773
+ if (!DECIMAL.test(text)) return text;
2774
+ const number = Number(text);
2775
+ if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
2776
+ const trimmed = text.replace(/\.?0+$/, "");
2777
+ return String(number) === trimmed ? number : text;
2940
2778
  }
2941
-
2942
- // src/commands/deployments.ts
2943
- function shortSha(sha) {
2944
- return sha ? sha.slice(0, 7) : "-";
2779
+ function quoteFilterValue(value) {
2780
+ const text = String(value);
2781
+ return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
2945
2782
  }
2946
- function formatDeploymentLine(deployment) {
2947
- return [
2948
- deployment.id.padEnd(38),
2949
- deployment.status.padEnd(10),
2950
- deployment.branch.padEnd(16),
2951
- shortSha(deployment.gitCommitSha).padEnd(9),
2952
- deployment.url ?? ""
2953
- ].join(" ");
2783
+ function keyword(text, at) {
2784
+ const word = text.toLowerCase();
2785
+ if (word === "null") return null;
2786
+ if (word === "true") return true;
2787
+ if (word === "false") return false;
2788
+ return refuse(
2789
+ `needs null, true or false after is at character ${String(at + 1)}`
2790
+ );
2954
2791
  }
2955
- async function runDeployments(context) {
2956
- const client = await context.client();
2957
- const projectId = await context.resolveProjectId();
2958
- const deployments = await client.deployments.list(projectId);
2959
- context.output.result({ projectId, deployments }, [
2960
- `Deployments for ${projectId}:`,
2961
- "",
2962
- [
2963
- "ID".padEnd(38),
2964
- "STATUS".padEnd(10),
2965
- "BRANCH".padEnd(16),
2966
- "COMMIT".padEnd(9),
2967
- "URL"
2968
- ].join(" "),
2969
- ...deployments.map(formatDeploymentLine)
2970
- ]);
2792
+ function attempt(parse) {
2793
+ try {
2794
+ return { ok: true, node: parse() };
2795
+ } catch (error) {
2796
+ if (error instanceof Refusal) return { ok: false, reason: error.message };
2797
+ throw error;
2798
+ }
2971
2799
  }
2972
- async function runInspect(context) {
2973
- const [deploymentId] = context.positionalOrThrow(
2974
- 1,
2975
- "Usage: naro inspect <deployment-id>"
2976
- );
2977
- const client = await context.client();
2978
- const detail = await client.deployments.get(deploymentId);
2979
- const { deployment } = detail;
2980
- const lastLogs = detail.logs.slice(-15);
2981
- context.output.result({ deployment, logs: detail.logs }, [
2982
- `Deployment ${deployment.id}`,
2983
- ` status: ${deployment.status}${deployment.errorCode ? ` (${deployment.errorCode})` : ""}`,
2984
- ` branch: ${deployment.branch}`,
2985
- ` commit: ${deployment.gitCommitSha ?? "-"}`,
2986
- ` url: ${deployment.url ?? "-"}`,
2987
- ` created: ${deployment.createdAt}`,
2988
- ` finished: ${deployment.finishedAt ?? "-"}`,
2989
- ...deployment.errorMessage ? [` error: ${deployment.errorMessage}`] : [],
2990
- ...lastLogs.length > 0 ? [
2991
- "",
2992
- "Recent build logs:",
2993
- ...lastLogs.map((log) => ` [${log.stage}] ${log.message}`)
2994
- ] : []
2995
- ]);
2800
+ function parseLogic(text, kind, options = {}) {
2801
+ if (typeof text !== "string")
2802
+ return { ok: false, reason: "must be a filter string or a function" };
2803
+ if (text.trim() === "") return { ok: false, reason: "must not be empty" };
2804
+ return attempt(() => {
2805
+ if (options.group === false) {
2806
+ const alone = items(text, options.tokens, 0);
2807
+ if (alone.length === 1 && alone[0]) return alone[0];
2808
+ }
2809
+ const nodes = items(text, options.tokens, 1);
2810
+ return kind === "or" ? { or: nodes } : { and: nodes };
2811
+ });
2812
+ }
2813
+ function items(text, tokens, level) {
2814
+ const parser = new Parser(text, tokens);
2815
+ const nodes = parser.list(level);
2816
+ if (parser.at() === ")") refuse(`has an unmatched ")" ${parser.where()}`);
2817
+ return nodes;
2996
2818
  }
2997
2819
 
2998
- // src/commands/domains.ts
2999
- var defaults3 = {
3000
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
3001
- now: () => Date.now()
2820
+ // src/commands/policy.ts
2821
+ 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>";
2822
+ var REMOVE_USAGE = "Usage: naro policy remove <table>";
2823
+ var CREATE_USAGE = 'Usage: naro policy create "CREATE POLICY <name> ON <table> \u2026" | --file <policies.sql>';
2824
+ var DROP_USAGE = "Usage: naro policy drop <name> --table <table>";
2825
+ var TAKES_EFFECT = "It takes effect within about 10 seconds.";
2826
+ var DEFAULT_DENY = "Tables without a policy deny anon and signed-in requests. The service key skips policies.";
2827
+ var RULE_FLAGS = {
2828
+ select: "selectRule",
2829
+ insert: "insertRule",
2830
+ update: "updateRule",
2831
+ delete: "deleteRule"
3002
2832
  };
3003
- var FIRST_DELAY_MS = 5e3;
3004
- var MAX_DELAY_MS = 3e4;
3005
- var WAIT_TIMEOUT_MS = 10 * 6e4;
3006
- function isReady(domain) {
3007
- return domain.ready ?? (domain.status === "active" && domain.sslStatus === "active");
2833
+ var WHEN_FLAGS = {
2834
+ select: { key: "selectWhen", tree: "using" },
2835
+ insert: { key: "insertWhen", tree: "check" },
2836
+ update: { key: "updateWhen", tree: "using" },
2837
+ delete: { key: "deleteWhen", tree: "using" }
2838
+ };
2839
+ var AUTH_UID = "auth.uid()";
2840
+ var OTHER_FLAGS = [
2841
+ "selectRule",
2842
+ "insertRule",
2843
+ "updateRule",
2844
+ "deleteRule",
2845
+ "ownerColumn",
2846
+ "selectWhen",
2847
+ "insertWhen",
2848
+ "updateWhen",
2849
+ "updateCheck",
2850
+ "deleteWhen",
2851
+ "signedIn"
2852
+ ];
2853
+ function isRule(value) {
2854
+ return POLICY_RULES.includes(value);
3008
2855
  }
3009
- function isTerminal(domain) {
3010
- return domain.ownership === "deleted" || domain.status === "blocked" || domain.status === "moved" || domain.status === "deleted";
2856
+ function refuse2(message) {
2857
+ throw new NaroError("VALIDATION_FAILED", message);
3011
2858
  }
3012
- var AXIS_DONE = /* @__PURE__ */ new Set(["verified", "active", "live"]);
3013
- var AXIS_FAILED = /* @__PURE__ */ new Set(["failed", "deleted"]);
3014
- function axisMark(value) {
3015
- if (AXIS_DONE.has(value)) return "\u2713";
3016
- if (AXIS_FAILED.has(value)) return "\u2717";
3017
- return "\xB7";
2859
+ function flagName(key) {
2860
+ return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
3018
2861
  }
3019
- function axisLines(domain) {
3020
- const axes = [
3021
- ["ownership", domain.ownership],
3022
- ["certificate", domain.certificate],
3023
- ["routing", domain.routing]
3024
- ];
3025
- return axes.filter((axis) => axis[1] !== void 0).map(
3026
- ([name, value]) => ` ${`${name}:`.padEnd(13)}${axisMark(value)} ${value}`
3027
- );
2862
+ function conditionTree(flag, text) {
2863
+ const parsed = parseLogic(text, "and", {
2864
+ tokens: { [AUTH_UID]: { auth: "uid" } },
2865
+ group: false
2866
+ });
2867
+ if (!parsed.ok) refuse2(`${flag} ${parsed.reason}.`);
2868
+ const tree = parsed.node;
2869
+ const checked = parsePolicyNode(tree, "");
2870
+ if (!checked.ok) {
2871
+ const path = checked.path.replace(/^\./, "");
2872
+ refuse2(
2873
+ path === "" ? `${flag} ${checked.reason}.` : `${flag}: ${path} ${checked.reason}.`
2874
+ );
2875
+ }
2876
+ return tree;
3028
2877
  }
3029
- function axisSummary(domain) {
3030
- const axes = [
3031
- domain.ownership,
3032
- domain.certificate,
3033
- domain.routing
3034
- ].filter((value) => value !== void 0);
3035
- return axes.length > 0 ? axes.join(" \xB7 ") : `${domain.status} \xB7 ssl ${domain.sslStatus}`;
2878
+ function conditionsFrom(flags) {
2879
+ const conditions = {};
2880
+ for (const action of POLICY_ACTIONS) {
2881
+ const { key, tree } = WHEN_FLAGS[action];
2882
+ const text = flags[key];
2883
+ if (text === void 0) continue;
2884
+ const flag = flagName(key);
2885
+ if (flags[RULE_FLAGS[action]] !== void 0)
2886
+ refuse2(`--${action} and ${flag} cannot be used together.`);
2887
+ const condition = { [tree]: conditionTree(flag, text) };
2888
+ if (action === "update" && flags.updateCheck !== void 0)
2889
+ condition.check = conditionTree("--update-check", flags.updateCheck);
2890
+ if (flags.signedIn) condition.role = "authenticated";
2891
+ conditions[action] = condition;
2892
+ }
2893
+ if (flags.updateCheck !== void 0 && flags.updateWhen === void 0)
2894
+ refuse2("--update-check needs --update-when.");
2895
+ if (flags.signedIn && Object.keys(conditions).length === 0)
2896
+ refuse2(
2897
+ "--signed-in needs --select-when, --insert-when, --update-when or --delete-when."
2898
+ );
2899
+ return conditions;
3036
2900
  }
3037
- function recordLines(domain) {
3038
- const instructions = domain.instructions ?? [];
3039
- if (instructions.length > 0) {
3040
- const lines2 = [" Required DNS records:"];
3041
- for (const instruction of instructions) {
3042
- lines2.push(
3043
- ` ${instruction.type.padEnd(5)} ${instruction.name}`,
3044
- ` host: ${instruction.host.padEnd(24)} value: ${instruction.value}`
2901
+ function patchFromFile(context, path, table) {
2902
+ let text;
2903
+ try {
2904
+ text = readFileSync4(resolve3(context.cwd, path), "utf8");
2905
+ } catch {
2906
+ refuse2(`Could not read ${path}.`);
2907
+ }
2908
+ let body;
2909
+ try {
2910
+ body = JSON.parse(text);
2911
+ } catch {
2912
+ refuse2(`${path} is not JSON.`);
2913
+ }
2914
+ if (typeof body !== "object" || body === null || Array.isArray(body))
2915
+ refuse2(`${path} must hold a JSON object.`);
2916
+ const { table: named, ...fields } = body;
2917
+ const patch = Object.fromEntries(
2918
+ Object.entries(fields).filter(([key]) => key !== "policies")
2919
+ );
2920
+ if (Object.hasOwn(body, "table")) {
2921
+ if (typeof named !== "string")
2922
+ refuse2(`${path}: table must be a string, the table's name.`);
2923
+ if (table !== void 0 && asciiLower(named) !== asciiLower(table))
2924
+ refuse2(
2925
+ `${path} is the policy of ${printable(named)}, not ${printable(table)}. Remove its "table" to set it on ${printable(table)}.`
3045
2926
  );
3046
- if (instruction.note) lines2.push(` note: ${instruction.note}`);
3047
- }
3048
- return lines2;
3049
2927
  }
3050
- if (domain.verification.length === 0) return [];
3051
- const lines = [" Required DNS records:"];
3052
- for (const record of domain.verification) {
3053
- lines.push(
3054
- ` Type: ${record.type.toUpperCase().padEnd(6)} Name: ${record.name}`,
3055
- ` Value: ${record.value}`
2928
+ const allowed = /* @__PURE__ */ new Set([...POLICY_ACTIONS, "ownerColumn"]);
2929
+ if (Object.keys(patch).some((key) => !allowed.has(key)))
2930
+ refuse2(
2931
+ `${path}: Only table, select, insert, update, delete and ownerColumn can be set.`
3056
2932
  );
3057
- }
3058
- return lines;
2933
+ return patch;
3059
2934
  }
3060
- function domainLines(domain) {
3061
- const lines = [
3062
- ` ${domain.hostname.padEnd(36)} ${domain.status.padEnd(12)} ssl: ${domain.sslStatus}`,
3063
- ...axisLines(domain)
3064
- ];
3065
- if (domain.redirectTo) {
3066
- const status = domain.redirectStatus ?? 308;
3067
- lines.push(` redirects to ${domain.redirectTo} (${String(status)})`);
2935
+ function asciiLower(text) {
2936
+ return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
2937
+ }
2938
+ function policyPatch(flags, context, table) {
2939
+ if (flags.fromFile !== void 0) {
2940
+ if (OTHER_FLAGS.some(
2941
+ (key) => flags[key] !== void 0 && flags[key] !== false
2942
+ ))
2943
+ refuse2(
2944
+ "--from-file takes the whole policy: leave out the other policy options."
2945
+ );
2946
+ if (!context) refuse2(SET_USAGE);
2947
+ return patchFromFile(context, flags.fromFile, table);
3068
2948
  }
3069
- if (domain.cause) {
3070
- lines.push(` \u2717 ${domain.cause.message}`);
3071
- if (domain.cause.fix) lines.push(` \u2192 ${domain.cause.fix}`);
2949
+ const patch = {};
2950
+ for (const action of POLICY_ACTIONS) {
2951
+ const value = flags[RULE_FLAGS[action]];
2952
+ if (value === void 0) continue;
2953
+ if (!isRule(value))
2954
+ refuse2(`--${action} must be one of ${POLICY_RULES.join(", ")}.`);
2955
+ patch[action] = value;
3072
2956
  }
3073
- if (isReady(domain)) return lines;
3074
- lines.push(...recordLines(domain));
3075
- const guidance = domain.guidance;
3076
- if (guidance && guidance.notes.length > 0) {
3077
- lines.push(
3078
- guidance.apex ? ` Apex domain (${domain.hostname}):` : " Note:"
3079
- );
3080
- for (const note of guidance.notes) lines.push(` ${note}`);
2957
+ Object.assign(patch, conditionsFrom(flags));
2958
+ if (flags.ownerColumn !== void 0) {
2959
+ if (flags.ownerColumn.trim() === "") refuse2("--owner must name a column.");
2960
+ patch.ownerColumn = flags.ownerColumn;
3081
2961
  }
3082
- return lines;
2962
+ if (Object.keys(patch).length === 0)
2963
+ refuse2(
2964
+ `Set at least one of --select, --insert, --update, --delete, --owner, --select-when, --insert-when, --update-when, --delete-when or --from-file. ${SET_USAGE}`
2965
+ );
2966
+ return patch;
3083
2967
  }
3084
- async function runDomainsList(context) {
3085
- const client = await context.client();
3086
- const projectId = await context.resolveProjectId();
3087
- const domains = await client.domains.list(projectId);
3088
- const pending = domains.filter((domain) => !isReady(domain));
3089
- context.output.result(
3090
- { projectId, domains },
3091
- domains.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
3092
- `Domains (${String(domains.length)}):`,
3093
- ...domains.flatMap(domainLines),
3094
- // list reads our stored copy; only verify goes and looks again.
3095
- ...pending.length > 0 ? ["", "Re-check DNS now: naro domains verify"] : []
3096
- ]
3097
- );
2968
+ var SHORT = {
2969
+ contains: "cs",
2970
+ containedBy: "cd",
2971
+ overlaps: "ov"
2972
+ };
2973
+ function isAuthUid(value) {
2974
+ return typeof value === "object" && value !== null && value.auth === "uid";
3098
2975
  }
3099
- async function runDomainsAdd(context) {
3100
- const [hostname] = context.positionalOrThrow(
3101
- 1,
3102
- "Usage: naro domains add <hostname> [--no-www]"
3103
- );
3104
- const client = await context.client();
3105
- const projectId = await context.resolveProjectId();
3106
- const withWww = context.flags.noWww !== true;
3107
- const domain = await client.domains.add(projectId, hostname, {
3108
- withWww
3109
- });
3110
- const companion = domain.companion;
3111
- const apex = domain.apex ?? domain.guidance?.apex ?? false;
3112
- const alternative = domain.guidance?.alternative ?? `www.${domain.hostname}`;
3113
- context.output.result(
3114
- { projectId, domain, withWww, ...companion ? { companion } : {} },
3115
- [
3116
- `\u2705 Domain added: ${domain.hostname}`,
3117
- "",
3118
- ...domainLines(domain),
3119
- ...companion ? [
3120
- "",
3121
- ` Also registered ${companion.hostname} \u2014 an apex domain often cannot hold`,
3122
- " the CNAME above, and this form always can.",
3123
- "",
3124
- ...domainLines(companion)
3125
- ] : [],
3126
- ...!companion && apex && !withWww ? [
3127
- "",
3128
- ` --no-www: ${domain.hostname} was registered on its own. If your registrar`,
3129
- ` refuses the record above, run \`naro domains add ${alternative}\`.`
3130
- ] : [],
3131
- "",
3132
- isReady(domain) ? "The domain is active." : "Status: Pending verification \u2014 add the DNS records above, then run `naro domains verify`."
3133
- ]
3134
- );
2976
+ function nearAuthUid(text) {
2977
+ return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "") === "auth.uid";
3135
2978
  }
3136
- async function verifyAll(client, projectId, hostname, auto = false) {
3137
- if (hostname !== void 0)
3138
- return [
3139
- await client.domains.verifyByHostname(projectId, hostname, { auto })
3140
- ];
3141
- const listed = await client.domains.list(projectId);
3142
- const verified = [];
3143
- for (const domain of listed)
3144
- verified.push(await client.domains.verify(projectId, domain.id, { auto }));
3145
- return verified;
2979
+ var DECIMAL2 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
2980
+ function readsAsNumber(value) {
2981
+ const text = String(value);
2982
+ if (!DECIMAL2.test(text) || !Object.is(Number(text), value)) return false;
2983
+ return text.includes(".") || Number.isSafeInteger(value);
2984
+ }
2985
+ function stringText(value, slot) {
2986
+ if (slot === "is") return void 0;
2987
+ if (slot === "like" && value.includes("*")) return void 0;
2988
+ const bare = value !== "" && value.trim() === value && !/[,()"{}\\]/.test(value) && !/^(?:true|false|null)$/i.test(value) && !nearAuthUid(value) && !(slot === "array" && DECIMAL2.test(value));
2989
+ return bare ? value : quoteFilterValue(value);
2990
+ }
2991
+ function valueText(value, slot) {
2992
+ if (isAuthUid(value))
2993
+ return slot === "like" || slot === "is" ? void 0 : AUTH_UID;
2994
+ if (value === null) return slot === "is" ? "null" : void 0;
2995
+ if (typeof value === "boolean")
2996
+ return slot === "is" || slot === "eq" || slot === "in" ? String(value) : void 0;
2997
+ if (typeof value === "number")
2998
+ return slot === "array" && readsAsNumber(value) ? String(value) : void 0;
2999
+ if (typeof value === "string") return stringText(value, slot);
3000
+ return void 0;
3146
3001
  }
3147
- async function runDomainsVerify(context, overrides = {}) {
3148
- const deps = { ...defaults3, ...overrides };
3149
- const client = await context.client();
3150
- const projectId = await context.resolveProjectId();
3151
- const hostname = context.positionals[0];
3152
- const wait = context.flags.wait === true;
3153
- const progress = (line) => {
3154
- if (!context.flags.json) context.output.log(line);
3155
- };
3156
- let domains = await verifyAll(client, projectId, hostname);
3157
- if (domains.length === 0) {
3158
- context.output.result({ projectId, domains, ready: false }, [
3159
- "No custom domains. Add one with `naro domains add example.com`."
3160
- ]);
3161
- return;
3002
+ function columnText(name) {
3003
+ if (name === "") return void 0;
3004
+ return /[.,()"\s]/.test(name) ? quoteFilterValue(name) : name;
3005
+ }
3006
+ function slotOf(operator) {
3007
+ if (operator === "eq" || operator === "neq") return "eq";
3008
+ if (operator === "like" || operator === "ilike") return "like";
3009
+ if (operator === "is") return "is";
3010
+ return "ordered";
3011
+ }
3012
+ function leafText(leaf, negated) {
3013
+ const column = columnText(leaf.column);
3014
+ if (column === void 0) return void 0;
3015
+ const prefix = `${column}.${negated ? "not." : ""}`;
3016
+ const short = SHORT[leaf.operator];
3017
+ if (leaf.operator === "in" || short !== void 0) {
3018
+ if (!Array.isArray(leaf.value)) return void 0;
3019
+ const slot = short === void 0 ? "in" : "array";
3020
+ const items2 = leaf.value.map((item) => valueText(item, slot));
3021
+ if (items2.some((item) => item === void 0)) return void 0;
3022
+ const list = items2.join(",");
3023
+ return slot === "in" ? `${prefix}in.(${list})` : `${prefix}${String(short)}.{${list}}`;
3162
3024
  }
3163
- let timedOut = false;
3164
- let abandoned = false;
3165
- if (wait && !domains.every(isReady)) {
3166
- const deadline = deps.now() + WAIT_TIMEOUT_MS;
3167
- let delay = FIRST_DELAY_MS;
3168
- for (; ; ) {
3169
- const pending = domains.filter((domain) => !isReady(domain));
3170
- if (pending.length > 0 && pending.every(isTerminal)) {
3171
- abandoned = true;
3172
- break;
3173
- }
3174
- const remaining = deadline - deps.now();
3175
- if (remaining <= 0) {
3176
- timedOut = true;
3177
- break;
3178
- }
3179
- for (const domain of pending)
3180
- progress(
3181
- isTerminal(domain) ? `\u26A0 ${domain.hostname}: ${axisSummary(domain)} \u2014 waiting cannot fix this one` : `\u25B8 ${domain.hostname}: ${axisSummary(domain)}`
3182
- );
3183
- await deps.sleep(Math.min(delay, remaining));
3184
- delay = Math.min(delay * 2, MAX_DELAY_MS);
3185
- domains = await verifyAll(client, projectId, hostname, true);
3186
- if (domains.every(isReady)) break;
3187
- }
3025
+ if (Array.isArray(leaf.value)) return void 0;
3026
+ const text = valueText(leaf.value, slotOf(leaf.operator));
3027
+ return text === void 0 ? void 0 : `${prefix}${leaf.operator}.${text}`;
3028
+ }
3029
+ function groupText(kind, children) {
3030
+ const texts = children.map((child) => filterText(child, false));
3031
+ if (texts.length === 0 || texts.some((text) => text === void 0))
3032
+ return void 0;
3033
+ return `${kind}(${texts.join(",")})`;
3034
+ }
3035
+ function filterText(node, top = true) {
3036
+ if ("operator" in node) return leafText(node, false);
3037
+ if ("not" in node) {
3038
+ const inner = node.not;
3039
+ if ("operator" in inner) return leafText(inner, true);
3040
+ if ("or" in inner) return negatedText(groupText("or", inner.or));
3041
+ if ("and" in inner) return negatedText(groupText("and", inner.and));
3042
+ return void 0;
3188
3043
  }
3189
- const ready = domains.every(isReady);
3190
- const body = domains.flatMap(domainLines);
3191
- if (!ready) {
3192
- for (const line of body) progress(line);
3193
- const notReady = domains.filter((domain) => !isReady(domain));
3194
- const names = notReady.map((domain) => domain.hostname).join(", ");
3195
- const details = { projectId, domains, ready: false };
3196
- if (timedOut) {
3197
- const cause2 = notReady[0]?.cause;
3198
- throw new NaroError(
3199
- "TIMEOUT",
3200
- `${names} did not go live within 10 minutes.${cause2 ? ` ${cause2.message}` : ""} DNS changes can take longer to propagate \u2014 re-run \`naro domains verify\` later.`,
3201
- { details }
3202
- );
3203
- }
3204
- if (abandoned) {
3205
- const blocked = domains.find(isTerminal);
3206
- throw new NaroError(
3207
- blocked?.cause?.code ?? "DOMAIN_VERIFICATION_FAILED",
3208
- blocked?.cause?.message ?? `${names} cannot be verified (status: ${blocked?.status ?? "unknown"}). Remove it and add it again.`,
3209
- { details }
3210
- );
3211
- }
3212
- const cause = notReady[0]?.cause;
3213
- throw new NaroError(
3214
- cause?.code ?? "DOMAIN_NOT_READY",
3215
- `${names} is not live yet.${cause ? ` ${cause.message}` : ""} Add the records above, then run \`naro domains verify --wait\`.`,
3216
- { details }
3217
- );
3044
+ if ("or" in node) return groupText("or", node.or);
3045
+ if (top && node.and.length > 1) {
3046
+ const texts = node.and.map((child) => filterText(child, false));
3047
+ return texts.some((text) => text === void 0) ? void 0 : texts.join(",");
3218
3048
  }
3219
- context.output.result({ projectId, domains, ready }, [
3220
- ...body,
3221
- "",
3222
- domains.length === 1 ? `\u2705 ${domains[0]?.hostname ?? ""} is live.` : "\u2705 Every domain is live."
3223
- ]);
3049
+ return groupText("and", node.and);
3224
3050
  }
3225
- async function runDomainsRemove(context) {
3226
- const [hostname] = context.positionalOrThrow(
3227
- 1,
3228
- "Usage: naro domains remove <hostname>"
3051
+ function negatedText(text) {
3052
+ return text === void 0 ? void 0 : `not.${text}`;
3053
+ }
3054
+ function isInvalid(value) {
3055
+ return typeof value === "object" && "invalid" in value;
3056
+ }
3057
+ function cellOf(value) {
3058
+ if (typeof value === "string") return value;
3059
+ return isInvalid(value) ? "invalid" : "condition";
3060
+ }
3061
+ function conditionText(action, value) {
3062
+ if (typeof value === "string") return value;
3063
+ if (isInvalid(value))
3064
+ return `cannot be read (${value.invalid}); it denies everyone but the service key`;
3065
+ const using = value.using ? filterText(value.using) : "";
3066
+ const check = value.check ? filterText(value.check) : "";
3067
+ if (using === void 0 || check === void 0)
3068
+ return `as JSON ${JSON.stringify(value)}`;
3069
+ const parts = [];
3070
+ if (using !== "") parts.push(`using ${using}`);
3071
+ if (check !== "") parts.push(`check ${check}`);
3072
+ else if (action === "update") parts.push("check = using");
3073
+ const text = parts.join("; ");
3074
+ return value.role === "authenticated" ? `${text} (signed-in users only)` : text;
3075
+ }
3076
+ function policyTable(policies) {
3077
+ const header = ["Table", ...POLICY_ACTIONS, "Owner column"];
3078
+ const rows = policies.map((policy) => [
3079
+ printable(policy.table),
3080
+ ...POLICY_ACTIONS.map((action) => cellOf(policy[action])),
3081
+ policy.ownerColumn === null ? "-" : printable(policy.ownerColumn)
3082
+ ]);
3083
+ const widths = header.map(
3084
+ (title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0))
3229
3085
  );
3230
- await confirmOrAbort(
3231
- context,
3232
- `Remove ${String(hostname)}? Traffic to it will stop routing here.`
3086
+ const table = [header, ...rows].map(
3087
+ (cells) => cells.map(
3088
+ (cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)
3089
+ ).join(" ").trimEnd()
3233
3090
  );
3234
- const client = await context.client();
3235
- const projectId = await context.resolveProjectId();
3236
- await client.domains.remove(projectId, hostname);
3237
- context.output.result({ projectId, hostname, removed: true }, [
3238
- `\u2705 Removed ${String(hostname)}`
3239
- ]);
3091
+ const lines = policies.flatMap(
3092
+ (policy) => POLICY_ACTIONS.filter((action) => typeof policy[action] !== "string").map(
3093
+ (action) => {
3094
+ const value = policy[action];
3095
+ const text = conditionText(action, value);
3096
+ return {
3097
+ label: `${printable(policy.table)}.${action}`,
3098
+ text: isInvalid(value) || isPrintable(text) ? printable(text) : `${UNSHOWN}: naro policy list --json has the exact text`
3099
+ };
3100
+ }
3101
+ )
3102
+ );
3103
+ if (lines.length === 0) return table;
3104
+ const width = Math.max(...lines.map((line) => line.label.length));
3105
+ return [
3106
+ ...table,
3107
+ "",
3108
+ ...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
3109
+ ];
3240
3110
  }
3241
-
3242
- // src/commands/env.ts
3243
- import { existsSync, writeFileSync as writeFileSync4 } from "fs";
3244
- import { join as join4 } from "path";
3245
- var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
3246
- var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3247
- function parseTarget(value) {
3248
- if (value === void 0) return "all";
3249
- if (!TARGETS.has(value))
3250
- throw new NaroError(
3251
- "VALIDATION_FAILED",
3252
- "Environment must be production, preview or all."
3111
+ var UNSHOWN = "has characters that cannot be shown here";
3112
+ function statementLine(policy) {
3113
+ if (policy.invalid !== null)
3114
+ return printable(
3115
+ unreadablePolicyComment({
3116
+ name: policy.name,
3117
+ table: policy.table,
3118
+ invalid: policy.invalid
3119
+ })
3253
3120
  );
3254
- return value;
3121
+ return shownStatement(policy, policy.table, "naro policy list --json");
3255
3122
  }
3256
- function maskedValue(row) {
3257
- if (!row.isSecret) return row.value ?? "";
3258
- return "\u2022\u2022\u2022\u2022\u2022\u2022 (secret)";
3123
+ function policyNamed(name, table) {
3124
+ return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(table))}`;
3259
3125
  }
3260
- async function runEnvList(context) {
3126
+ function shownStatement(statement, table, exact) {
3127
+ if (isPrintable(statement.sql)) return statement.sql;
3128
+ return printable(
3129
+ `-- ${policyNamed(statement.name, table)} ${UNSHOWN}, so it is not printed as CREATE POLICY: ${exact} has its exact text.`
3130
+ );
3131
+ }
3132
+ function policyStatements(policies) {
3133
+ const lines = [];
3134
+ let table;
3135
+ for (const policy of policies) {
3136
+ if (table !== void 0 && policy.table !== table) lines.push("");
3137
+ table = policy.table;
3138
+ lines.push(statementLine(policy));
3139
+ }
3140
+ return lines;
3141
+ }
3142
+ async function runPolicyList(context) {
3143
+ const table = context.flags.table;
3261
3144
  const client = await context.client();
3262
3145
  const projectId = await context.resolveProjectId();
3263
- const reveal = context.flags.reveal;
3264
- const rows = await client.env.list(projectId, { reveal });
3265
- context.output.result(
3266
- {
3267
- projectId,
3268
- envVars: rows.map((row) => ({
3269
- id: row.id,
3270
- key: row.key,
3271
- target: row.target,
3272
- isSecret: row.isSecret,
3273
- value: row.isSecret && !reveal ? null : row.value
3274
- }))
3275
- },
3276
- [
3277
- `Environment variables (${String(rows.length)}):`,
3278
- ...rows.map(
3279
- (row) => ` ${row.key.padEnd(32)} ${row.target.padEnd(12)} ${reveal ? row.value ?? "" : maskedValue(row)}`
3280
- )
3281
- ]
3146
+ const policies = await client.backend.rlsPolicies(
3147
+ projectId,
3148
+ table === void 0 ? {} : { table }
3282
3149
  );
3150
+ let lines;
3151
+ if (policies.length > 0)
3152
+ lines = [...policyStatements(policies), "", DEFAULT_DENY];
3153
+ else if (table !== void 0)
3154
+ lines = [
3155
+ `No policies on ${printable(table)}: anon and signed-in requests to it are denied.`
3156
+ ];
3157
+ else
3158
+ lines = [
3159
+ "No policies yet: every table denies anon and signed-in requests.",
3160
+ 'Add one with `naro policy create "CREATE POLICY \u2026"` or `naro policy set <table> --select public`.'
3161
+ ];
3162
+ context.output.result({ projectId, policies }, lines);
3163
+ }
3164
+ function statementText(context) {
3165
+ const [positional] = context.positionals;
3166
+ const file = context.flags.file;
3167
+ if (positional !== void 0 && file !== void 0)
3168
+ refuse2(`Give the statement or --file, not both. ${CREATE_USAGE}`);
3169
+ let text = positional;
3170
+ if (file !== void 0) {
3171
+ try {
3172
+ text = readFileSync4(resolve3(context.cwd, file), "utf8");
3173
+ } catch {
3174
+ refuse2(`Could not read ${file}.`);
3175
+ }
3176
+ }
3177
+ if (text === void 0 || text.trim() === "") refuse2(CREATE_USAGE);
3178
+ return text;
3283
3179
  }
3284
- async function runEnvAdd(context) {
3285
- context.positionalOrThrow(
3286
- 1,
3287
- "Usage: naro env add KEY [production|preview|all]"
3288
- );
3289
- const [key, targetArg] = context.positionals;
3290
- if (!key || !KEY_RE.test(key) || key.length > 256)
3291
- throw new NaroError(
3292
- "VALIDATION_FAILED",
3293
- "Keys must look like MY_VARIABLE (letters, digits, underscore)."
3180
+ function policyStatementsOf(text) {
3181
+ const split = splitPolicyStatements(text);
3182
+ if (!split.ok)
3183
+ refuse2(`${split.reason} at character ${String(split.position)}.`);
3184
+ const { statements } = split;
3185
+ if (statements.length === 0) refuse2(CREATE_USAGE);
3186
+ statements.forEach((statement, index) => {
3187
+ const parsed = parsePolicyStatement(statement);
3188
+ if (parsed.ok) return;
3189
+ const where = `${parsed.reason} at character ${String(parsed.position)}.`;
3190
+ refuse2(
3191
+ statements.length === 1 ? where : `Statement ${String(index + 1)} of ${String(statements.length)}: ${where} Nothing was sent.`
3294
3192
  );
3295
- const target = parseTarget(targetArg);
3296
- let value;
3297
- if (!process.stdin.isTTY) {
3298
- value = await readStdin();
3299
- } else {
3300
- value = await context.prompter.askHidden(`? What's the value of ${key}? `);
3193
+ });
3194
+ return statements;
3195
+ }
3196
+ function resultLine(result) {
3197
+ if ("created" in result)
3198
+ return `\u2705 Created policy ${printable(result.created.name)} on ${printable(result.created.table)}.`;
3199
+ if (result.dropped === null)
3200
+ return "Nothing to drop: no such policy (IF EXISTS).";
3201
+ return `\u2705 Dropped policy ${printable(result.dropped.name)} on ${printable(result.dropped.table)}.`;
3202
+ }
3203
+ async function runStatements(client, projectId, statements, allowUnindexed) {
3204
+ const applied = [];
3205
+ for (const [index, statement] of statements.entries()) {
3206
+ try {
3207
+ applied.push(
3208
+ await client.backend.runPolicySql(
3209
+ projectId,
3210
+ statement,
3211
+ allowUnindexed ? { allowUnindexed } : {}
3212
+ )
3213
+ );
3214
+ } catch (error) {
3215
+ if (!isNaroError(error) || statements.length === 1) throw error;
3216
+ let before = "Nothing ran.";
3217
+ if (index === 1)
3218
+ before = "Statement 1 ran and stays; the rest were not sent.";
3219
+ else if (index > 1)
3220
+ before = `Statements 1\u2013${String(index)} ran and stay; the rest were not sent.`;
3221
+ throw new NaroError(
3222
+ error.code,
3223
+ `Statement ${String(index + 1)} of ${String(statements.length)}: ${error.message} ${before}`,
3224
+ {
3225
+ ...error.status === void 0 ? {} : { status: error.status },
3226
+ details: { ...error.details, applied },
3227
+ cause: error
3228
+ }
3229
+ );
3230
+ }
3301
3231
  }
3302
- if (value.length === 0)
3303
- throw new NaroError("VALIDATION_FAILED", `No value provided for ${key}.`);
3304
- const isSecret = !key.startsWith("NEXT_PUBLIC_");
3232
+ return applied;
3233
+ }
3234
+ async function runPolicyCreate(context) {
3235
+ const statements = policyStatementsOf(statementText(context));
3305
3236
  const client = await context.client();
3306
3237
  const projectId = await context.resolveProjectId();
3307
- await client.env.set(projectId, [{ key, value, target, isSecret }]);
3308
- context.output.result({ projectId, key, target, isSecret }, [
3309
- `\u2705 Added ${key} (${target}${isSecret ? ", secret" : ""})`
3238
+ const applied = await runStatements(
3239
+ client,
3240
+ projectId,
3241
+ statements,
3242
+ context.flags.allowUnindexed === true
3243
+ );
3244
+ context.output.result({ projectId, applied }, [
3245
+ ...applied.map(resultLine),
3246
+ applied.length === 1 ? TAKES_EFFECT : "They take effect within about 10 seconds."
3310
3247
  ]);
3311
3248
  }
3312
- async function runEnvRemove(context) {
3313
- context.positionalOrThrow(
3314
- 1,
3315
- "Usage: naro env remove KEY [production|preview|all]"
3316
- );
3317
- const [key, targetArg] = context.positionals;
3318
- const target = targetArg === void 0 ? void 0 : parseTarget(targetArg);
3249
+ async function runPolicyDrop(context) {
3250
+ const [name] = context.positionalOrThrow(1, DROP_USAGE);
3251
+ const table = context.flags.table;
3252
+ if (table === void 0 || table === "") refuse2(DROP_USAGE);
3319
3253
  await confirmOrAbort(
3320
3254
  context,
3321
- `Remove ${String(key)}${target ? ` (${target})` : ""} from this project?`
3255
+ `Drop policy ${printable(name)} on ${printable(table)}? It stops applying within about 10 seconds.`
3322
3256
  );
3323
3257
  const client = await context.client();
3324
3258
  const projectId = await context.resolveProjectId();
3325
- const removed = await client.env.remove(projectId, key, target);
3326
- context.output.result({ projectId, key, removed }, [
3327
- `\u2705 Removed ${String(key)} (${String(removed)} entr${removed === 1 ? "y" : "ies"})`
3259
+ const dropped = await client.backend.dropRlsPolicy(
3260
+ projectId,
3261
+ table,
3262
+ name
3263
+ );
3264
+ context.output.result({ projectId, dropped }, [
3265
+ `\u2705 Dropped policy ${printable(dropped.name)} on ${printable(dropped.table)}. It stops applying within about 10 seconds.`
3328
3266
  ]);
3329
3267
  }
3330
- async function runEnvPull(context) {
3331
- const file = context.positionals[0] ?? ".env.local";
3332
- const surface = context.flags.env === "production" ? "production" : "preview";
3333
- if (context.flags.env && !["production", "preview"].includes(context.flags.env))
3334
- throw new NaroError(
3335
- "VALIDATION_FAILED",
3336
- "--env must be production or preview."
3337
- );
3268
+ async function runPolicySet(context) {
3269
+ const [table] = context.positionalOrThrow(1, SET_USAGE);
3270
+ const patch = policyPatch(context.flags, context, table);
3338
3271
  const client = await context.client();
3339
3272
  const projectId = await context.resolveProjectId();
3340
- const rows = await client.env.list(projectId, { reveal: true });
3341
- const selected = rows.filter(
3342
- (row) => row.target === "all" || row.target === surface
3343
- );
3344
- const vars = Object.fromEntries(
3345
- selected.map((row) => [row.key, row.value ?? ""])
3273
+ const policy = await client.backend.setPolicy(
3274
+ projectId,
3275
+ table,
3276
+ patch
3346
3277
  );
3347
- const path = join4(context.cwd, file);
3348
- if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
3349
- const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
3350
- if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
3278
+ let named;
3279
+ try {
3280
+ named = await client.backend.rlsPolicies(projectId, {
3281
+ table: policy.table
3282
+ });
3283
+ } catch (error) {
3284
+ if (!isNaroError(error)) throw error;
3351
3285
  }
3352
- writeFileSync4(
3353
- path,
3354
- `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
3355
- ${serializeDotenv(vars)}`,
3356
- { mode: 384 }
3357
- );
3358
3286
  context.output.result(
3359
- { projectId, file, environment: surface, count: selected.length },
3360
- [`\u2705 Pulled ${String(selected.length)} variable(s) into ${file}`]
3287
+ { projectId, policy, ...named === void 0 ? {} : { policies: named } },
3288
+ [
3289
+ `\u2705 Saved the policy for ${printable(policy.table)}. ${TAKES_EFFECT}`,
3290
+ ...policyTable([policy]),
3291
+ ...named === void 0 || named.length === 0 ? [] : ["", ...policyStatements(named)]
3292
+ ]
3361
3293
  );
3362
3294
  }
3363
-
3364
- // src/commands/gen.ts
3365
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
3366
- import { dirname as dirname2, resolve as resolve3 } from "path";
3367
- var LANG_MESSAGE = "naro gen types writes TypeScript only. Pass --lang typescript, or leave --lang out.";
3368
- async function runGenTypes(context) {
3369
- const { lang } = context.flags;
3370
- if (lang !== void 0 && lang !== "typescript")
3371
- throw new NaroError("VALIDATION_FAILED", LANG_MESSAGE);
3295
+ async function runPolicyRemove(context) {
3296
+ const [table] = context.positionalOrThrow(1, REMOVE_USAGE);
3297
+ await confirmOrAbort(
3298
+ context,
3299
+ `Remove every policy on ${printable(table)}? Anon and signed-in requests to it are denied from then on.`
3300
+ );
3372
3301
  const client = await context.client();
3373
3302
  const projectId = await context.resolveProjectId();
3374
- const tables = await client.db.tables(projectId);
3375
- const read = await policiesForTypes(client, projectId);
3376
- const types = generateTypes(
3377
- tables,
3378
- read.policies === void 0 ? {} : { policies: read.policies }
3379
- );
3380
- const undescribed = tables.flatMap(
3381
- (table) => table.error === void 0 ? [] : [{ name: table.name, error: table.error }]
3382
- );
3383
- for (const { name, error } of undescribed)
3384
- context.output.warn(
3385
- `\u26A0 ${printable(name)} could not be described and was generated as an empty interface: ${printable(error)}`
3386
- );
3387
- if (read.policies === void 0)
3388
- context.output.warn(
3389
- `\u26A0 The backend's policies could not be read (${printable(read.reason)}), so owner columns stay required in Insert.`
3390
- );
3391
- const extra = {
3392
- ...undescribed.length > 0 ? { undescribed } : {},
3393
- ...read.policies === void 0 ? { policies: "unavailable", policiesReason: read.reason } : {}
3394
- };
3395
- if (context.flags.output) {
3396
- const path = resolve3(context.cwd, context.flags.output);
3397
- mkdirSync4(dirname2(path), { recursive: true });
3398
- writeFileSync5(path, types);
3399
- context.output.result(
3400
- {
3401
- projectId,
3402
- output: context.flags.output,
3403
- tables: tables.length,
3404
- ...extra
3405
- },
3406
- [
3407
- `\u2705 Wrote types for ${String(tables.length)} table(s) to ${context.flags.output}`
3408
- ]
3409
- );
3410
- return;
3411
- }
3412
- context.output.result({ projectId, types, ...extra }, [
3413
- types.replace(/\n$/, "")
3303
+ const policy = await client.backend.removePolicy(projectId, table);
3304
+ const names = policy.policies ?? [];
3305
+ context.output.result({ projectId, removed: true, policy }, [
3306
+ names.length === 0 ? `\u2705 Removed the policy for ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.` : `\u2705 Removed ${names.length === 1 ? "policy" : "policies"} ${names.map(printable).join(", ")} on ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.`
3414
3307
  ]);
3415
3308
  }
3416
3309
 
3417
- // src/commands/logs.ts
3418
- var FOLLOW_INTERVAL_MS = 3e3;
3419
- function sleep(ms) {
3420
- return new Promise((resolve5) => setTimeout(resolve5, ms));
3421
- }
3422
- function formatBuildLog(log) {
3423
- return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
3310
+ // src/commands/db-schema.ts
3311
+ var USAGE2 = {
3312
+ table: "Usage: naro db table create|add-column|rename-column|rename|drop-column|drop \u2026 (see naro help)",
3313
+ index: "Usage: naro db index create|drop \u2026 (see naro help)",
3314
+ create: "Usage: naro db table create <name> --column <spec> [--column <spec>\u2026] [--owner <column>] [--if-not-exists]",
3315
+ addColumn: "Usage: naro db table add-column <table> --column <spec>",
3316
+ renameColumn: "Usage: naro db table rename-column <table> <column> <new-name>",
3317
+ rename: "Usage: naro db table rename <table> <new-name>",
3318
+ dropColumn: "Usage: naro db table drop-column <table> <column>",
3319
+ drop: "Usage: naro db table drop <table>",
3320
+ indexCreate: "Usage: naro db index create <table> <column[,column\u2026]> [--unique] [--name <name>]",
3321
+ indexDrop: "Usage: naro db index drop <name>"
3322
+ };
3323
+ var SPEC_USAGE = '--column needs name:type, e.g. --column "title:text:notnull".';
3324
+ var ON_DELETE = {
3325
+ cascade: "CASCADE",
3326
+ "set-null": "SET NULL",
3327
+ "set-default": "SET DEFAULT",
3328
+ restrict: "RESTRICT",
3329
+ "no-action": "NO ACTION"
3330
+ };
3331
+ var KEYWORDS = /* @__PURE__ */ new Set(["CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME"]);
3332
+ var NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
3333
+ function segments(spec) {
3334
+ const parts = [];
3335
+ let current = "";
3336
+ let quoted = false;
3337
+ for (const char of spec) {
3338
+ if (char === "'") quoted = !quoted;
3339
+ if (char === ":" && !quoted) {
3340
+ parts.push(current);
3341
+ current = "";
3342
+ continue;
3343
+ }
3344
+ current += char;
3345
+ }
3346
+ if (quoted) return null;
3347
+ parts.push(current);
3348
+ return parts;
3424
3349
  }
3425
- function formatRuntimeLog(entry) {
3426
- const time = new Date(entry.ts).toISOString();
3427
- const request = entry.method && entry.path ? ` ${entry.method} ${entry.path}${entry.status ? ` ${String(entry.status)}` : ""}` : "";
3428
- return `${time} [${entry.level}]${request} ${entry.message}`.trimEnd();
3350
+ function defaultValue(text) {
3351
+ if (text.length >= 2 && text.startsWith("'") && text.endsWith("'"))
3352
+ return text.slice(1, -1).replace(/''/g, "'");
3353
+ const lower = text.toLowerCase();
3354
+ if (lower === "null") return null;
3355
+ if (lower === "true") return true;
3356
+ if (lower === "false") return false;
3357
+ const upper = text.toUpperCase();
3358
+ if (KEYWORDS.has(upper)) return { keyword: upper };
3359
+ if (NUMBER.test(text)) return Number(text);
3360
+ return text;
3429
3361
  }
3430
- async function latestProductionDeploymentId(client, projectId) {
3431
- const deployments = await client.deployments.list(projectId);
3432
- const latest = deployments.find(
3433
- (deployment) => deployment.status === "READY"
3434
- );
3435
- const fallback = deployments[0];
3436
- const chosen = latest ?? fallback;
3437
- if (!chosen)
3362
+ function parseColumnSpec(spec) {
3363
+ const shown = `--column "${printable(spec)}"`;
3364
+ const parts = segments(spec);
3365
+ if (parts === null)
3438
3366
  throw new NaroError(
3439
- "DEPLOYMENT_NOT_FOUND",
3440
- "This project has no deployments yet."
3441
- );
3442
- return chosen.id;
3443
- }
3444
- async function buildLogs(context, client, deploymentId) {
3445
- if (!context.flags.follow) {
3446
- const detail = await client.deployments.get(deploymentId);
3447
- context.output.result(
3448
- { deploymentId, type: "build", logs: detail.logs },
3449
- detail.logs.map(formatBuildLog)
3367
+ "VALIDATION_FAILED",
3368
+ `${shown}: a quoted default is not closed.`
3450
3369
  );
3451
- return;
3370
+ const [name, type, ...modifiers] = parts;
3371
+ if (!name || !type) throw new NaroError("VALIDATION_FAILED", SPEC_USAGE);
3372
+ const column = {
3373
+ name,
3374
+ type
3375
+ };
3376
+ let onDelete;
3377
+ for (const modifier of modifiers) {
3378
+ const eq = modifier.indexOf("=");
3379
+ const key = (eq < 0 ? modifier : modifier.slice(0, eq)).toLowerCase();
3380
+ const value = eq < 0 ? void 0 : modifier.slice(eq + 1);
3381
+ if (value === void 0 && key === "pk") column.primaryKey = true;
3382
+ else if (value === void 0 && key === "autoincrement")
3383
+ column.autoIncrement = true;
3384
+ else if (value === void 0 && key === "notnull") column.notNull = true;
3385
+ else if (value === void 0 && key === "unique") column.unique = true;
3386
+ else if (value !== void 0 && key === "default")
3387
+ column.default = defaultValue(value);
3388
+ else if (value !== void 0 && key === "references") {
3389
+ const dot = value.lastIndexOf(".");
3390
+ const table = dot < 0 ? "" : value.slice(0, dot);
3391
+ const target = dot < 0 ? "" : value.slice(dot + 1);
3392
+ if (!table || !target)
3393
+ throw new NaroError(
3394
+ "VALIDATION_FAILED",
3395
+ `${shown}: references needs <table>.<column>.`
3396
+ );
3397
+ column.references = { table, column: target };
3398
+ } else if (value !== void 0 && key === "ondelete") {
3399
+ onDelete = ON_DELETE[value.toLowerCase()];
3400
+ if (!onDelete)
3401
+ throw new NaroError(
3402
+ "VALIDATION_FAILED",
3403
+ `${shown}: ondelete takes cascade, set-null, set-default, restrict or no-action.`
3404
+ );
3405
+ } else
3406
+ throw new NaroError(
3407
+ "VALIDATION_FAILED",
3408
+ `${shown}: unknown modifier ${printable(modifier)}. Use pk, autoincrement, notnull, unique, default=<value>, references=<table>.<column> or ondelete=<action>.`
3409
+ );
3452
3410
  }
3453
- let lastSequence = -1;
3454
- for (; ; ) {
3455
- const detail = await client.deployments.get(deploymentId);
3456
- for (const log of detail.logs) {
3457
- if (log.sequence > lastSequence) {
3458
- lastSequence = log.sequence;
3459
- context.output.log(formatBuildLog(log));
3460
- }
3461
- }
3462
- if (isTerminalStatus(detail.deployment.status)) {
3463
- context.output.result(
3464
- {
3465
- deploymentId,
3466
- type: "build",
3467
- status: detail.deployment.status,
3468
- logs: detail.logs
3469
- },
3470
- [`\u2014 deployment ${detail.deployment.status} \u2014`]
3411
+ if (onDelete !== void 0) {
3412
+ if (!column.references)
3413
+ throw new NaroError(
3414
+ "VALIDATION_FAILED",
3415
+ `${shown}: ondelete needs references=<table>.<column>.`
3471
3416
  );
3472
- return;
3473
- }
3474
- await sleep(FOLLOW_INTERVAL_MS);
3417
+ column.references.onDelete = onDelete;
3475
3418
  }
3419
+ return column;
3476
3420
  }
3477
- async function runtimeLogs(context, client, projectId) {
3478
- const environment = context.flags.env === "preview" ? "preview" : "production";
3479
- if (context.flags.env && !["production", "preview"].includes(context.flags.env))
3480
- throw new NaroError(
3481
- "VALIDATION_FAILED",
3482
- "--env must be production or preview."
3421
+ function policyLine(policy, dryRun = false) {
3422
+ const rules = [policy.select, policy.insert, policy.update, policy.delete];
3423
+ const owner = policy.ownerColumn === null ? "" : ` (owner column ${printable(policy.ownerColumn)})`;
3424
+ const uniform = rules.every((rule) => rule === rules[0]);
3425
+ const summary = uniform ? `${String(rules[0])} for select, insert, update and delete` : `select ${policy.select}, insert ${policy.insert}, update ${policy.update}, delete ${policy.delete}`;
3426
+ const head = dryRun ? "Would set the Data API policy" : "\u2713 Data API policy";
3427
+ return `${head} on ${printable(policy.table)}: ${summary}${owner}.`;
3428
+ }
3429
+ function ruleText(value) {
3430
+ if (typeof value === "string") return value;
3431
+ return "invalid" in value ? "invalid" : "condition";
3432
+ }
3433
+ var SUGAR_NAME = /^naro_(?:select|insert|update|delete)$/;
3434
+ function removedLines(policy, dryRun) {
3435
+ const verb = dryRun ? "Would remove" : "\u2713 Removed";
3436
+ const names = policy.policies ?? [];
3437
+ let line;
3438
+ if (names.every((name) => SUGAR_NAME.test(name))) {
3439
+ const owner = policy.ownerColumn === null ? "" : `, owner column ${printable(policy.ownerColumn)}`;
3440
+ const rules = `select ${ruleText(policy.select)}, insert ${ruleText(policy.insert)}, update ${ruleText(policy.update)}, delete ${ruleText(policy.delete)}${owner}`;
3441
+ line = `${verb} the Data API policy on ${printable(policy.table)} (${rules}).`;
3442
+ } else
3443
+ line = `${verb} ${String(names.length)} Data API ${names.length === 1 ? "policy" : "policies"} on ${printable(policy.table)}: ${names.map(printable).join(", ")}.`;
3444
+ const heading = " As SQL, for naro policy create:";
3445
+ const statements = policy.statements;
3446
+ if (statements === void 0) {
3447
+ const sql = policy.sql ?? "";
3448
+ if (sql === "") return [line];
3449
+ return isPrintable(sql) ? [line, heading, sql] : [
3450
+ line,
3451
+ " The SQL has characters that cannot be shown here: --json has the exact text."
3452
+ ];
3453
+ }
3454
+ const owners = statements.filter(
3455
+ (statement) => typeof statement.ownerColumn === "string"
3456
+ );
3457
+ const created = statements.filter((statement) => !owners.includes(statement));
3458
+ const lines = [line];
3459
+ if (created.length > 0)
3460
+ lines.push(
3461
+ heading,
3462
+ ...created.map(
3463
+ (statement) => shownStatement(statement, policy.table, "--json")
3464
+ )
3483
3465
  );
3484
- const options = {
3485
- environment,
3486
- ...context.flags.level ? { level: context.flags.level } : {}
3487
- };
3488
- if (!context.flags.follow) {
3489
- const entries = await client.logs.runtime(projectId, options);
3490
- context.output.result(
3491
- { projectId, type: "runtime", environment, entries },
3492
- entries.length > 0 ? entries.map(formatRuntimeLog) : ["(no runtime logs yet \u2014 the worker logs on each request)"]
3466
+ if (owners.length > 0) {
3467
+ const one = owners.length === 1;
3468
+ const command = policy.setCommand;
3469
+ lines.push(
3470
+ ` As a command, for the owner ${one ? "rule" : "rules"} (${owners.map((owner) => printable(owner.name)).join(", ")}): CREATE POLICY has no owner column, so naro policy create cannot make ${one ? "it" : "them"} again.`,
3471
+ // The server's bytes, or not shown: a name may hold a line break.
3472
+ typeof command !== "string" ? " naro policy set <table> --<action> owner --owner <column> sets one." : isPrintable(command) ? command : " The command has characters that cannot be shown here: --json has the exact text."
3493
3473
  );
3494
- return;
3495
3474
  }
3496
- context.output.log(
3497
- `Following ${environment} runtime logs (Ctrl-C to stop)...`
3475
+ return lines;
3476
+ }
3477
+ function removedBeforeFailure(error) {
3478
+ if (!isNaroError(error)) return [];
3479
+ const removed = error.details?.removedPolicies;
3480
+ if (!Array.isArray(removed)) return [];
3481
+ return removed.filter(
3482
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.table === "string" && entry.ownerColumn !== void 0
3498
3483
  );
3499
- let lastTs = -1;
3500
- let boundary = /* @__PURE__ */ new Set();
3501
- for (; ; ) {
3502
- const entries = await client.logs.runtime(projectId, options);
3503
- for (const entry of entries) {
3504
- if (entry.ts < lastTs) continue;
3505
- const key = JSON.stringify(entry);
3506
- if (entry.ts === lastTs) {
3507
- if (boundary.has(key)) continue;
3508
- } else {
3509
- lastTs = entry.ts;
3510
- boundary = /* @__PURE__ */ new Set();
3511
- }
3512
- boundary.add(key);
3513
- context.output.log(formatRuntimeLog(entry));
3514
- }
3515
- await sleep(FOLLOW_INTERVAL_MS);
3516
- }
3517
3484
  }
3518
- async function runLogs(context) {
3485
+ async function runSchemaChange(context, operations, question) {
3486
+ const dryRun = context.flags.dryRun === true;
3487
+ if (question !== void 0 && !dryRun)
3488
+ await confirmOrAbort(context, question);
3489
+ const sent = dryRun ? operations.map((operation) => {
3490
+ if (operation.op !== "dropTable" && operation.op !== "dropColumn")
3491
+ return operation;
3492
+ const { confirm: _confirm, ...rest } = operation;
3493
+ return rest;
3494
+ }) : operations;
3519
3495
  const client = await context.client();
3520
- const [deploymentId] = context.positionals;
3521
- const type = context.flags.type ?? (deploymentId !== void 0 ? "build" : "runtime");
3522
- if (type !== "build" && type !== "runtime")
3523
- throw new NaroError(
3524
- "VALIDATION_FAILED",
3525
- "--type must be build or runtime."
3526
- );
3527
- if (type === "runtime") {
3528
- if (deploymentId)
3529
- throw new NaroError(
3530
- "VALIDATION_FAILED",
3531
- "Runtime logs are per project, not per deployment. Drop the deployment id or use --type build."
3532
- );
3533
- const projectId = await context.resolveProjectId();
3534
- await runtimeLogs(context, client, projectId);
3496
+ const projectId = await context.resolveProjectId();
3497
+ const dir = migrationsDir(context);
3498
+ const local = listMigrationFiles(dir);
3499
+ const ledger = await appliedMigrations(client, projectId);
3500
+ const pending = pendingMigrationNames(local, ledger);
3501
+ if (pending.length > 0 && !dryRun)
3502
+ throw migrationsPendingError(pending.map(printable));
3503
+ const name = nextMigrationFileName(
3504
+ [...local, ...ledger.map((row) => row.name)],
3505
+ schemaMigrationTitle(operations[0] ?? {})
3506
+ );
3507
+ const result = await client.db.schema(projectId, sent, {
3508
+ ...dryRun ? { dryRun: true } : {},
3509
+ name
3510
+ }).catch((error) => {
3511
+ if (!context.flags.json)
3512
+ for (const policy of removedBeforeFailure(error))
3513
+ for (const line of removedLines(policy, false))
3514
+ context.output.warn(line);
3515
+ throw error;
3516
+ });
3517
+ const sqlLines = result.sql.trimEnd().split("\n").map(printable);
3518
+ const removed = result.removedPolicies ?? [];
3519
+ if (result.dryRun) {
3520
+ for (const warning of result.warnings)
3521
+ context.output.warn(`\u26A0 ${printable(warning)}`);
3522
+ context.output.result({ projectId, ...result, file: null }, [
3523
+ ...sqlLines,
3524
+ `Dry run \u2014 nothing was applied. It would be recorded as ${printable(result.name)}.`,
3525
+ ...result.policies.map((policy) => policyLine(policy, true)),
3526
+ ...removed.flatMap((policy) => removedLines(policy, true))
3527
+ ]);
3535
3528
  return;
3536
3529
  }
3537
- const target = deploymentId ?? await latestProductionDeploymentId(
3538
- client,
3539
- await context.resolveProjectId()
3530
+ const allWarnings = result.name === name ? result.warnings : [...result.warnings, migrationNameMismatch(name, result.name)];
3531
+ let file = null;
3532
+ let fileWarning;
3533
+ const relDir = relativeToCwd(context, dir);
3534
+ const rel = join4(relDir === "." ? "" : relDir, name);
3535
+ if (context.flags.noWrite) {
3536
+ 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.`;
3537
+ } else {
3538
+ try {
3539
+ mkdirSync3(dir, { recursive: true });
3540
+ writeFileSync3(join4(dir, name), result.sql, { flag: "wx" });
3541
+ file = rel;
3542
+ } catch (error) {
3543
+ const reason = (error instanceof Error ? error.message : String(error)).replace(/[.\s]+$/, "");
3544
+ 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.`;
3545
+ }
3546
+ }
3547
+ for (const warning of allWarnings)
3548
+ context.output.warn(`\u26A0 ${printable(warning)}`);
3549
+ if (fileWarning) context.output.warn(`\u26A0 ${printable(fileWarning)}`);
3550
+ context.output.result(
3551
+ {
3552
+ projectId,
3553
+ ...result,
3554
+ warnings: allWarnings,
3555
+ file,
3556
+ ...fileWarning ? { fileWarning } : {}
3557
+ },
3558
+ [
3559
+ ...sqlLines,
3560
+ file === null ? `\u2705 Applied ${printable(result.name)}.` : `\u2705 Applied ${printable(result.name)} and wrote ${printable(file)}.`,
3561
+ ...result.policies.map((policy) => policyLine(policy)),
3562
+ ...removed.flatMap((policy) => removedLines(policy, false))
3563
+ ]
3540
3564
  );
3541
- await buildLogs(context, client, target);
3542
3565
  }
3543
-
3544
- // src/commands/policy.ts
3545
- import { readFileSync as readFileSync3 } from "fs";
3546
- import { resolve as resolve4 } from "path";
3547
-
3548
- // ../../packages/naro-sdk/src/query/filters.ts
3549
- var MAX_FILTER_DEPTH = 4;
3550
- var OPERATORS = {
3551
- eq: "eq",
3552
- neq: "neq",
3553
- gt: "gt",
3554
- gte: "gte",
3555
- lt: "lt",
3556
- lte: "lte",
3557
- in: "in",
3558
- is: "is",
3559
- like: "like",
3560
- ilike: "ilike",
3561
- contains: "contains",
3562
- containedBy: "containedBy",
3563
- overlaps: "overlaps",
3564
- cs: "contains",
3565
- cd: "containedBy",
3566
- ov: "overlaps"
3567
- };
3568
- function operatorOf(name) {
3569
- return typeof name === "string" && Object.hasOwn(OPERATORS, name) ? OPERATORS[name] : void 0;
3566
+ function columnsFlag(context) {
3567
+ return context.flags.columns ?? [];
3568
+ }
3569
+ async function runDbTableUsage() {
3570
+ throw new NaroError("VALIDATION_FAILED", USAGE2.table);
3571
+ }
3572
+ async function runDbIndexUsage() {
3573
+ throw new NaroError("VALIDATION_FAILED", USAGE2.index);
3574
+ }
3575
+ async function runDbTableCreate(context) {
3576
+ const [table] = context.positionals;
3577
+ const specs = columnsFlag(context);
3578
+ if (!table || specs.length === 0)
3579
+ throw new NaroError("VALIDATION_FAILED", USAGE2.create);
3580
+ const columns = specs.map(parseColumnSpec);
3581
+ const owner = context.flags.ownerColumn;
3582
+ await runSchemaChange(context, [
3583
+ {
3584
+ op: "createTable",
3585
+ table,
3586
+ columns,
3587
+ ...context.flags.ifNotExists ? { ifNotExists: true } : {},
3588
+ ...owner === void 0 ? {} : { owner: { column: owner } }
3589
+ }
3590
+ ]);
3591
+ }
3592
+ async function runDbTableAddColumn(context) {
3593
+ const [table] = context.positionals;
3594
+ const specs = columnsFlag(context);
3595
+ const [spec] = specs;
3596
+ if (!table || spec === void 0 || specs.length !== 1)
3597
+ throw new NaroError("VALIDATION_FAILED", USAGE2.addColumn);
3598
+ await runSchemaChange(context, [
3599
+ { op: "addColumn", table, column: parseColumnSpec(spec) }
3600
+ ]);
3601
+ }
3602
+ async function runDbTableRenameColumn(context) {
3603
+ const [table, column, to] = context.positionals;
3604
+ if (!table || !column || !to)
3605
+ throw new NaroError("VALIDATION_FAILED", USAGE2.renameColumn);
3606
+ await runSchemaChange(context, [{ op: "renameColumn", table, column, to }]);
3607
+ }
3608
+ async function runDbTableRename(context) {
3609
+ const [table, to] = context.positionals;
3610
+ if (!table || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
3611
+ await runSchemaChange(context, [{ op: "renameTable", table, to }]);
3612
+ }
3613
+ async function runDbTableDropColumn(context) {
3614
+ const [table, column] = context.positionals;
3615
+ if (!table || !column)
3616
+ throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
3617
+ const target = `${table}.${column}`;
3618
+ await runSchemaChange(
3619
+ context,
3620
+ [{ op: "dropColumn", table, column, confirm: target }],
3621
+ `Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
3622
+ );
3623
+ }
3624
+ async function runDbTableDrop(context) {
3625
+ const [table] = context.positionals;
3626
+ if (!table) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
3627
+ await runSchemaChange(
3628
+ context,
3629
+ [{ op: "dropTable", table, confirm: table }],
3630
+ `Drop table ${printable(table)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
3631
+ );
3570
3632
  }
3571
- var Refusal = class extends Error {
3572
- };
3573
- function refuse(reason) {
3574
- throw new Refusal(reason);
3633
+ async function runDbIndexCreate(context) {
3634
+ const [table, list] = context.positionals;
3635
+ const columns = listFlag(list);
3636
+ if (!table || columns.length === 0)
3637
+ throw new NaroError("VALIDATION_FAILED", USAGE2.indexCreate);
3638
+ const name = context.flags.name;
3639
+ await runSchemaChange(context, [
3640
+ {
3641
+ op: "createIndex",
3642
+ table,
3643
+ columns,
3644
+ ...context.flags.unique ? { unique: true } : {},
3645
+ ...name === void 0 ? {} : { name }
3646
+ }
3647
+ ]);
3575
3648
  }
3576
- var LIST_HINT = {
3577
- in: ["(", ")", "needs a list in parentheses, like in.(a,b),"],
3578
- array: ["{", "}", "needs a list in braces, like cs.{a,b},"]
3579
- };
3580
- function isSpace(char) {
3581
- return char !== "" && char.trim() === "";
3649
+ async function runDbIndexDrop(context) {
3650
+ const [name] = context.positionals;
3651
+ if (!name) throw new NaroError("VALIDATION_FAILED", USAGE2.indexDrop);
3652
+ await runSchemaChange(context, [{ op: "dropIndex", name }]);
3582
3653
  }
3583
- function looseWord(text) {
3584
- return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "");
3654
+
3655
+ // src/commands/git.ts
3656
+ var defaults2 = {
3657
+ detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
3658
+ openBrowser,
3659
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
3660
+ };
3661
+ function connected(context, project) {
3662
+ const branch = project.defaultBranch;
3663
+ context.output.result(
3664
+ {
3665
+ projectId: project.id,
3666
+ repositoryFullName: project.repositoryFullName,
3667
+ autoDeploy: true
3668
+ },
3669
+ [
3670
+ `\u2705 Connected to ${project.repositoryFullName ?? "the repository"}.`,
3671
+ ` Pushes to ${branch} now deploy automatically.`,
3672
+ "",
3673
+ " To turn this off: naro git disconnect"
3674
+ ]
3675
+ );
3585
3676
  }
3586
- var Parser = class {
3587
- constructor(text, tokens = {}) {
3588
- this.text = text;
3589
- this.tokens = tokens;
3590
- }
3591
- index = 0;
3592
- /**
3593
- * A token at the next value, followed only by spaces and then one of
3594
- * `stops` or the end — `auth.uid()x` is text, not the token.
3595
- */
3596
- token(stops) {
3597
- this.skipSpaces();
3598
- const at = this.index;
3599
- for (const [word, value] of Object.entries(this.tokens)) {
3600
- if (!this.text.startsWith(word, at)) continue;
3601
- let end = at + word.length;
3602
- while (isSpace(this.text[end] ?? "")) end++;
3603
- const next = this.text[end] ?? "";
3604
- if (next !== "" && !stops.includes(next)) continue;
3605
- this.index = end;
3606
- return { value, word, at };
3677
+ async function runGitConnect(context, overrides = {}) {
3678
+ const deps = { ...defaults2, ...overrides };
3679
+ const { output } = context;
3680
+ const explicit = context.positionals[0];
3681
+ let fullName;
3682
+ if (explicit) {
3683
+ const parsed = parseGitHubRemote(explicit) ?? parseGitHubRemote(`https://github.com/${explicit}`);
3684
+ if (!parsed)
3685
+ throw new NaroError(
3686
+ "VALIDATION_FAILED",
3687
+ `"${explicit}" is not an owner/repo pair.`
3688
+ );
3689
+ fullName = parsed.fullName;
3690
+ } else {
3691
+ const remote = await deps.detectRemote(context.cwd);
3692
+ const parsed = remote ? parseGitHubRemote(remote) : null;
3693
+ if (!parsed) {
3694
+ throw new NaroError(
3695
+ "NO_GIT_REMOTE",
3696
+ remote ? `Could not read a GitHub owner/repo from "${remote}". Pass it directly: naro git connect <owner/repo>` : "This directory has no GitHub origin remote. Pass one directly: naro git connect <owner/repo>"
3697
+ );
3607
3698
  }
3608
- return void 0;
3609
- }
3610
- at() {
3611
- return this.text[this.index] ?? "";
3612
- }
3613
- /**
3614
- * An unquoted value that is one of the tokens misspelled — `auth.uid`,
3615
- * `AUTH.UID` — is refused: read as text it would compare with the words
3616
- * themselves (사용자 리뷰 반영). Quoted, it is the text.
3617
- */
3618
- notNearToken(value, at) {
3619
- const loose = looseWord(value);
3620
- for (const word of Object.keys(this.tokens))
3621
- if (loose !== "" && loose === looseWord(word))
3622
- refuse(
3623
- `has ${JSON.stringify(value)} ${this.where(at)}: did you mean ${word}? Quote it to mean the text`
3624
- );
3625
- }
3626
- /** `scalar`, with an unquoted value held to notNearToken. */
3627
- bareOrQuoted(stops) {
3628
- this.skipSpaces();
3629
- const at = this.index;
3630
- const quoted = this.at() === '"';
3631
- const text = this.scalar(stops);
3632
- if (!quoted) this.notNearToken(text, at);
3633
- return { text, quoted };
3699
+ fullName = parsed.fullName;
3634
3700
  }
3635
- where(index = this.index) {
3636
- return `at character ${String(index + 1)}`;
3701
+ const client = await context.client();
3702
+ const projectId = await context.resolveProjectId();
3703
+ const first = await client.projects.connectGit(projectId, fullName);
3704
+ if (first.ok) {
3705
+ connected(context, first.project);
3706
+ return;
3637
3707
  }
3638
- skipSpaces() {
3639
- while (isSpace(this.at())) this.index++;
3708
+ if (!first.installUrl) {
3709
+ throw new NaroError(
3710
+ "REPOSITORY_NOT_ACCESSIBLE",
3711
+ `Naro cannot reach ${fullName}, and no install URL was offered. Connect it from the dashboard.`
3712
+ );
3640
3713
  }
3641
- /** `item ("," item)*`, up to a `)` or the end, inside `level` groups. */
3642
- list(level) {
3643
- const nodes = [];
3644
- for (; ; ) {
3645
- nodes.push(this.item(level));
3646
- this.skipSpaces();
3647
- const char = this.at();
3648
- if (char === ",") this.index++;
3649
- else if (char === ")" || char === "") return nodes;
3650
- else refuse(`has unexpected text ${this.where()}`);
3714
+ output.log(`
3715
+ \u25B8 Naro needs access to ${fullName}.`);
3716
+ output.log(` Opening ${first.installUrl}`);
3717
+ deps.openBrowser(first.installUrl);
3718
+ output.log(" Waiting for the installation to finish...\n");
3719
+ await waitForInstall(context, projectId, fullName, deps);
3720
+ }
3721
+ async function waitForInstall(context, projectId, fullName, deps) {
3722
+ const client = await context.client();
3723
+ const project = await client.projects.get(projectId);
3724
+ const teams = await client.teams.list();
3725
+ const teamSlug = teams.find((team) => team.id === project.teamId)?.slug;
3726
+ if (teamSlug) {
3727
+ const attempts = deps.pollAttempts ?? 60;
3728
+ const interval = deps.pollIntervalMs ?? 3e3;
3729
+ for (let attempt2 = 0; attempt2 < attempts; attempt2 += 1) {
3730
+ await deps.sleep(interval);
3731
+ let reachable = false;
3732
+ try {
3733
+ const repositories = await client.github.listRepositories(teamSlug);
3734
+ reachable = repositories.some(
3735
+ (repository) => repository.fullName.toLowerCase() === fullName.toLowerCase()
3736
+ );
3737
+ } catch {
3738
+ }
3739
+ if (!reachable) continue;
3740
+ const retry = await client.projects.connectGit(projectId, fullName);
3741
+ if (retry.ok) {
3742
+ connected(context, retry.project);
3743
+ return;
3744
+ }
3651
3745
  }
3652
3746
  }
3653
- item(level) {
3654
- this.skipSpaces();
3655
- const start = this.index;
3656
- const char = this.at();
3657
- if (char === "" || char === "," || char === ")")
3658
- refuse(`has an empty item ${this.where()}`);
3659
- const group = /^(not\.)?(and|or)\(/.exec(this.text.slice(start));
3660
- if (!group) return this.leaf(level);
3661
- const negated = group[1] !== void 0;
3662
- const inner = level + (negated ? 2 : 1);
3663
- if (inner > MAX_FILTER_DEPTH)
3664
- refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
3665
- const open = start + group[0].length - 1;
3666
- this.index = open + 1;
3667
- const children = this.list(inner);
3668
- if (this.at() !== ")") refuse(`has an unclosed "(" ${this.where(open)}`);
3669
- this.index++;
3670
- const node = group[2] === "or" ? { or: children } : { and: children };
3671
- return negated ? { not: node } : node;
3672
- }
3673
- /** `column.[not.]operator.value`. */
3674
- leaf(level) {
3675
- const start = this.index;
3676
- const column = this.at() === '"' ? this.quoted() : this.word().trim();
3677
- if (column === "" || this.at() !== ".")
3678
- refuse(`needs column.operator.value ${this.where(start)}`);
3679
- this.index++;
3680
- let nameAt = this.index;
3681
- let name = this.word();
3682
- let negated = false;
3683
- if (name === "not" && this.at() === ".") {
3684
- if (level + 1 > MAX_FILTER_DEPTH)
3685
- refuse(`nests deeper than ${String(MAX_FILTER_DEPTH)} groups`);
3686
- negated = true;
3687
- this.index++;
3688
- nameAt = this.index;
3689
- name = this.word();
3747
+ context.output.result(
3748
+ { projectId, repositoryFullName: null, autoDeploy: false },
3749
+ [
3750
+ `\u25B8 Still waiting on access to ${fullName}.`,
3751
+ " Finish the installation, then run: naro git connect"
3752
+ ]
3753
+ );
3754
+ }
3755
+ async function runGitDisconnect(context) {
3756
+ const client = await context.client();
3757
+ const projectId = await context.resolveProjectId();
3758
+ const project = await client.projects.disconnectGit(projectId);
3759
+ context.output.result({ projectId: project.id, autoDeploy: false }, [
3760
+ `\u2705 ${project.name} no longer deploys on push.`,
3761
+ " Deploy from here with: naro deploy"
3762
+ ]);
3763
+ }
3764
+ async function offerAutoDeploy(context, projectId, overrides) {
3765
+ const deps = { ...defaults2, ...overrides };
3766
+ if (overrides.canPrompt === false) return;
3767
+ try {
3768
+ if (readProjectLink(context.cwd)?.autoDeployDeclined === true) return;
3769
+ const connection = overrides.gitConnection;
3770
+ if (connection.state === "connected" || connection.state === "none") return;
3771
+ const fullName = connection.fullName;
3772
+ if (!fullName) return;
3773
+ let wanted;
3774
+ if (connection.state === "unreachable") {
3775
+ const installed = connection.installedAccounts ?? [];
3776
+ context.output.log(
3777
+ `
3778
+ \u25B8 Automatic deploys need the Naro GitHub App on \`${connection.repoOwner ?? fullName}\`.`
3779
+ );
3780
+ if (overrides.teamSlug && installed.length > 0)
3781
+ context.output.log(
3782
+ ` Team ${overrides.teamSlug} has it on: ${installed.join(", ")}.`
3783
+ );
3784
+ wanted = await context.prompter.confirm(
3785
+ "Install it for this team now? (opens your browser)"
3786
+ );
3787
+ } else {
3788
+ wanted = await context.prompter.confirm(
3789
+ "Should your changes deploy automatically from now on?"
3790
+ );
3690
3791
  }
3691
- const operator = operatorOf(name);
3692
- if (this.at() !== ".") {
3693
- if (name !== "" && operator === void 0)
3694
- refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
3695
- refuse(`needs column.operator.value ${this.where(start)}`);
3792
+ if (!wanted) {
3793
+ const link = readProjectLink(context.cwd);
3794
+ writeProjectLink(context.cwd, {
3795
+ ...link ?? { projectId },
3796
+ autoDeployDeclined: true
3797
+ });
3798
+ context.output.log(
3799
+ "\u25B8 Not now. Later, if you change your mind: naro git connect"
3800
+ );
3801
+ return;
3696
3802
  }
3697
- if (operator === void 0)
3698
- refuse(`has an unknown operator "${name}" ${this.where(nameAt)}`);
3699
- this.index++;
3700
- const leaf = { operator, column, value: this.value(operator) };
3701
- return negated ? { not: leaf } : leaf;
3803
+ const client = await context.client();
3804
+ const result = await client.projects.connectGit(projectId, fullName);
3805
+ if (result.ok) {
3806
+ connected(context, result.project);
3807
+ return;
3808
+ }
3809
+ if (!result.installUrl) return;
3810
+ context.output.log(`
3811
+ \u25B8 Naro needs access to ${fullName}.`);
3812
+ context.output.log(` Opening ${result.installUrl}`);
3813
+ deps.openBrowser(result.installUrl);
3814
+ context.output.log(" Waiting for the installation to finish...\n");
3815
+ await waitForInstall(context, projectId, fullName, deps);
3816
+ } catch (error) {
3817
+ context.output.log(
3818
+ `\u25B8 Could not set up automatic deploys (${error instanceof Error ? error.message : String(error)}).`
3819
+ );
3820
+ context.output.log(" Try later with: naro git connect");
3702
3821
  }
3703
- /** Up to the next `.`, `,`, `(`, `)` or the end. */
3704
- word() {
3705
- const start = this.index;
3706
- while (this.index < this.text.length && !".,()".includes(this.at()))
3707
- this.index++;
3708
- return this.text.slice(start, this.index);
3822
+ }
3823
+
3824
+ // src/commands/link.ts
3825
+ var SWITCH_ACCOUNT = "Sign in as a different account\u2026";
3826
+ async function resolveTeam(context, teams, deps = {}) {
3827
+ const explicit = context.flags.team ?? process.env.NARO_TEAM;
3828
+ if (explicit) {
3829
+ const match = teams.find((team) => team.slug === explicit);
3830
+ if (!match)
3831
+ throw new NaroError(
3832
+ "NOT_FOUND",
3833
+ `You are not a member of team "${explicit}".`
3834
+ );
3835
+ return match;
3709
3836
  }
3710
- /** `"…"`, with `\` making the next character literal. */
3711
- quoted() {
3712
- const start = this.index;
3713
- this.index++;
3714
- let text = "";
3715
- for (; ; ) {
3716
- if (this.index >= this.text.length)
3717
- refuse(`has an unclosed quote ${this.where(start)}`);
3718
- const char = this.at();
3719
- this.index++;
3720
- if (char === '"') return text;
3721
- if (char === "\\" && this.index < this.text.length) {
3722
- text += this.at();
3723
- this.index++;
3724
- } else text += char;
3725
- }
3837
+ const linked = context.projectLink();
3838
+ if (linked?.teamSlug) {
3839
+ const match = teams.find((team) => team.slug === linked.teamSlug);
3840
+ if (match) return match;
3726
3841
  }
3727
- /** A quoted value, or everything up to `stops`, trimmed. */
3728
- scalar(stops) {
3729
- this.skipSpaces();
3730
- if (this.at() === '"') {
3731
- const text = this.quoted();
3732
- this.skipSpaces();
3733
- if (this.at() !== "" && !stops.includes(this.at()))
3734
- refuse(`has text after a closing quote ${this.where()}`);
3735
- return text;
3736
- }
3737
- const start = this.index;
3738
- while (this.index < this.text.length && !stops.includes(this.at()))
3739
- this.index++;
3740
- return this.text.slice(start, this.index).trim();
3842
+ if (teams.length === 0)
3843
+ throw new NaroError("NOT_FOUND", "Your account has no teams yet.");
3844
+ const only = teams.length === 1 ? teams[0] : null;
3845
+ const deploying = (deps.intent ?? "deploy") === "deploy";
3846
+ if (only && !deploying) return only;
3847
+ const canPrompt = deps.canPrompt?.() ?? (!context.flags.json && !context.flags.yes && isInteractive());
3848
+ if (!canPrompt)
3849
+ throw new NaroError(
3850
+ "TEAM_REQUIRED",
3851
+ `Cannot pick a team without a prompt. Pass --team <slug>, set NARO_TEAM, or run \`naro link\` first.
3852
+ Your teams: ${teams.map((team) => team.slug).join(", ")}`
3853
+ );
3854
+ const account = deps.accountLabel ? await deps.accountLabel() : "this account";
3855
+ if (only) {
3856
+ const label2 = only.isPersonal ? `${only.name} (personal)` : only.name;
3857
+ context.output.log(`
3858
+ ${account} \u2192 ${label2}`);
3859
+ const ok = await context.prompter.confirm(
3860
+ "Deploy to this account and team?"
3861
+ );
3862
+ if (ok) return only;
3863
+ return switchAndRetry(context, deps);
3741
3864
  }
3742
- /**
3743
- * `(a,b)` for `in`: text values, except that an unquoted `true` or `false`
3744
- * is a boolean (사용자 리뷰 반영, as in `eq`). `{a,b}` for the array
3745
- * operators: text, except that an unquoted exact decimal is a number
3746
- * (적대적 리뷰 반영, narrowed by 사용자 리뷰 반영) — json_each's values
3747
- * have no type affinity to turn the text "1" into 1, so as text `cs.{1}`
3748
- * matched no numeric array and `not.ov.{1}` matched every row. Quoted,
3749
- * `"1"` and `"true"` stay text.
3750
- */
3751
- listValue(kind) {
3752
- const [open, close, hint] = LIST_HINT[kind];
3753
- this.skipSpaces();
3754
- const openAt = this.index;
3755
- if (this.at() !== open) refuse(`${hint} ${this.where()}`);
3756
- this.index++;
3757
- this.skipSpaces();
3758
- const values = [];
3759
- if (this.at() === close) {
3760
- this.index++;
3761
- return values;
3762
- }
3763
- for (; ; ) {
3764
- this.skipSpaces();
3765
- const itemAt = this.index;
3766
- const token = this.token(`,${close}`);
3767
- if (token) values.push(token.value);
3768
- else {
3769
- const { text: value, quoted } = this.bareOrQuoted(`,${close}`);
3770
- if (!quoted && value === "") {
3771
- if (this.index >= this.text.length)
3772
- refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
3773
- refuse(`has an empty item ${this.where(itemAt)}`);
3774
- }
3775
- values.push(
3776
- quoted ? value : kind === "array" ? exactNumber(value) : truth(value)
3777
- );
3778
- }
3779
- if (this.index >= this.text.length)
3780
- refuse(`has an unclosed "${open}" ${this.where(openAt)}`);
3781
- const char = this.at();
3782
- this.index++;
3783
- if (char === close) return values;
3865
+ const options = [
3866
+ ...teams.map(
3867
+ (team) => `${team.name}${team.isPersonal ? " (personal)" : ""}`
3868
+ ),
3869
+ SWITCH_ACCOUNT
3870
+ ];
3871
+ const index = await context.prompter.select(
3872
+ deploying ? `Deploying as ${account} \u2014 which team owns this project?` : `Signed in as ${account}. Which team?`,
3873
+ options
3874
+ );
3875
+ const picked = teams[index];
3876
+ if (picked) return picked;
3877
+ return switchAndRetry(context, deps);
3878
+ }
3879
+ async function switchAndRetry(context, deps) {
3880
+ if (!deps.switchAccount || !deps.listTeams)
3881
+ throw new NaroError(
3882
+ "CANCELED",
3883
+ "Canceled. Switch accounts with `naro logout && naro login`."
3884
+ );
3885
+ await deps.switchAccount();
3886
+ const teams = await deps.listTeams();
3887
+ return resolveTeam(context, teams, deps);
3888
+ }
3889
+ function saveLink(context, project, teamSlug) {
3890
+ writeProjectLink(context.cwd, {
3891
+ projectId: project.id,
3892
+ teamSlug,
3893
+ projectName: project.name
3894
+ });
3895
+ if (ensureGitignoreHasNaro(context.cwd))
3896
+ context.output.log("\u25B8 added .naro to .gitignore");
3897
+ }
3898
+ async function runLink(context) {
3899
+ const client = await context.client();
3900
+ const teams = await client.teams.list();
3901
+ const team = await resolveTeam(context, teams, { intent: "read" });
3902
+ const projects = await client.projects.list(team.slug);
3903
+ let project;
3904
+ if (context.flags.project) {
3905
+ project = projects.find(
3906
+ (candidate) => candidate.id === context.flags.project
3907
+ );
3908
+ if (!project) {
3909
+ project = await client.projects.get(context.flags.project);
3784
3910
  }
3785
- }
3786
- /** The value after `operator.`. */
3787
- value(operator) {
3788
- const start = this.index;
3789
- switch (operator) {
3790
- case "in":
3791
- return this.listValue("in");
3792
- case "contains":
3793
- case "containedBy":
3794
- case "overlaps":
3795
- return this.listValue("array");
3796
- case "is":
3797
- return keyword(this.scalar(",)"), start);
3798
- case "like":
3799
- case "ilike": {
3800
- const token = this.token(",)");
3801
- if (token)
3802
- refuse(
3803
- `cannot use ${token.word} with ${operator} ${this.where(token.at)}`
3804
- );
3805
- return likePattern(this.bareOrQuoted(",)").text);
3806
- }
3807
- case "eq":
3808
- case "neq": {
3809
- const token = this.token(",)");
3810
- if (token) return token.value;
3811
- const { text, quoted } = this.bareOrQuoted(",)");
3812
- return quoted ? text : truth(text);
3813
- }
3814
- default: {
3815
- const token = this.token(",)");
3816
- return token ? token.value : this.bareOrQuoted(",)").text;
3817
- }
3911
+ } else if (context.flags.name) {
3912
+ project = projects.find(
3913
+ (candidate) => candidate.name === context.flags.name || candidate.slug === context.flags.name
3914
+ );
3915
+ if (!project)
3916
+ throw new NaroError(
3917
+ "PROJECT_NOT_FOUND",
3918
+ `No project named "${context.flags.name}" in team ${team.slug}. Run \`naro deploy\` to create it.`
3919
+ );
3920
+ } else {
3921
+ const guess = slugify(context.cwd.split("/").at(-1) ?? "app");
3922
+ const exact = projects.find(
3923
+ (candidate) => candidate.name === guess || candidate.slug === guess
3924
+ );
3925
+ if (exact && (context.flags.yes || context.flags.json)) {
3926
+ project = exact;
3927
+ } else if (projects.length === 0) {
3928
+ throw new NaroError(
3929
+ "PROJECT_NOT_FOUND",
3930
+ `Team ${team.slug} has no projects yet. Run \`naro deploy\` to create one.`
3931
+ );
3932
+ } else {
3933
+ const options = projects.map(
3934
+ (candidate) => `${candidate.name} (${candidate.subdomain})`
3935
+ );
3936
+ const index = await context.prompter.select(
3937
+ `Link ${context.cwd} to which project?`,
3938
+ options
3939
+ );
3940
+ project = projects[index];
3818
3941
  }
3819
3942
  }
3820
- };
3821
- function likePattern(pattern) {
3822
- return pattern.replaceAll("*", "%");
3943
+ if (!project)
3944
+ throw new NaroError("PROJECT_NOT_FOUND", "No project selected.");
3945
+ const owningTeam = teams.find((candidate) => candidate.id === project.teamId) ?? team;
3946
+ saveLink(context, project, owningTeam.slug);
3947
+ context.output.result(
3948
+ {
3949
+ projectId: project.id,
3950
+ projectName: project.name,
3951
+ teamSlug: owningTeam.slug,
3952
+ subdomain: project.subdomain
3953
+ },
3954
+ [`\u2705 Linked to ${owningTeam.slug}/${project.name} (.naro/project.json)`]
3955
+ );
3823
3956
  }
3824
- function truth(text) {
3825
- const word = text.toLowerCase();
3826
- if (word === "true") return true;
3827
- if (word === "false") return false;
3828
- return text;
3957
+ async function runUnlink(context) {
3958
+ const existing = readProjectLink(context.cwd);
3959
+ const removed = removeProjectLink(context.cwd);
3960
+ context.output.result(
3961
+ { removed, ...existing ? { projectId: existing.projectId } : {} },
3962
+ [removed ? "\u2705 Unlinked (.naro removed)" : "Nothing to unlink here."]
3963
+ );
3829
3964
  }
3830
- var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
3831
- function exactNumber(text) {
3832
- if (!DECIMAL.test(text)) return text;
3833
- const number = Number(text);
3834
- if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
3835
- const trimmed = text.replace(/\.?0+$/, "");
3836
- return String(number) === trimmed ? number : text;
3965
+
3966
+ // src/commands/deploy.ts
3967
+ async function runDeploy(context, deps = { deploy: deployDirectory, offerAutoDeploy }) {
3968
+ const { flags, output } = context;
3969
+ const link = flags.project ? { projectId: flags.project, teamSlug: void 0 } : readProjectLink(context.cwd);
3970
+ if (flags.prod && !flags.yes && !flags.json && isInteractive()) {
3971
+ const confirmed = await context.prompter.confirm("Deploy to production?");
3972
+ if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
3973
+ }
3974
+ output.log(`
3975
+ \u{1F680} Deploying ${context.cwd}
3976
+ `);
3977
+ const canPrompt = deps.canPrompt?.() ?? (!flags.json && !flags.yes && isInteractive());
3978
+ const team = await resolveDestination(context, link, canPrompt);
3979
+ const account = await accountLabel(context);
3980
+ output.log(` ${account} \u2192 ${team.slug}
3981
+ `);
3982
+ const result = await deps.deploy(
3983
+ context.cwd,
3984
+ {
3985
+ name: flags.name,
3986
+ projectId: link?.projectId,
3987
+ teamSlug: team.slug,
3988
+ rootDirectory: flags.root ?? null,
3989
+ branch: flags.branch,
3990
+ production: flags.prod,
3991
+ ...canPrompt && {
3992
+ onMissingEnv: (keys) => promptForMissingEnv(
3993
+ { cwd: context.cwd, prompter: context.prompter, output },
3994
+ keys
3995
+ )
3996
+ },
3997
+ token: flags.token,
3998
+ allowLogin: !flags.json && !flags.yes
3999
+ },
4000
+ (message) => output.log(message)
4001
+ );
4002
+ const existing = readProjectLink(context.cwd);
4003
+ writeProjectLink(context.cwd, {
4004
+ projectId: result.projectId,
4005
+ teamSlug: result.teamSlug,
4006
+ projectName: result.projectSlug,
4007
+ ...existing?.autoDeployDeclined === true ? { autoDeployDeclined: true } : {}
4008
+ });
4009
+ if (!link) {
4010
+ if (ensureGitignoreHasNaro(context.cwd))
4011
+ output.log("\u25B8 added .naro to .gitignore");
4012
+ output.log("\u25B8 linked (.naro/project.json)");
4013
+ }
4014
+ await deps.offerAutoDeploy?.(context, result.projectId, {
4015
+ canPrompt,
4016
+ gitConnection: result.gitConnection,
4017
+ teamSlug: result.teamSlug
4018
+ });
4019
+ const inspect = `${controlPlaneUrl()}${projectPath(result.teamSlug, result.projectSlug)}`;
4020
+ context.output.result(
4021
+ {
4022
+ deploymentId: result.deploymentId,
4023
+ projectId: result.projectId,
4024
+ status: result.status,
4025
+ url: result.url,
4026
+ target: result.production ? "production" : "preview",
4027
+ teamSlug: result.teamSlug,
4028
+ inspectUrl: inspect,
4029
+ missingEnvKeys: result.missingEnvKeys
4030
+ },
4031
+ [
4032
+ "",
4033
+ `\u2705 Deployment ready (${result.production ? "production" : "preview"})`,
4034
+ "",
4035
+ ` ${result.url}`,
4036
+ ` Inspect ${inspect}`
4037
+ ]
4038
+ );
3837
4039
  }
3838
- function quoteFilterValue(value) {
3839
- const text = String(value);
3840
- return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
4040
+ async function resolveDestination(context, link, canPrompt) {
4041
+ const client = await context.client();
4042
+ const teams = await client.teams.list();
4043
+ const deps = scopeDeps(context, canPrompt);
4044
+ if (link?.teamSlug) {
4045
+ const match = teams.find((team) => team.slug === link.teamSlug);
4046
+ if (match) return match;
4047
+ } else if (link) {
4048
+ let project;
4049
+ try {
4050
+ project = await client.projects.get(link.projectId);
4051
+ } catch (error) {
4052
+ if (isNaroError(error) && error.code !== "NOT_FOUND") throw error;
4053
+ throw new NaroError(
4054
+ "PROJECT_NOT_FOUND",
4055
+ `This directory is linked to a project ${await accountLabel(context)} cannot see.
4056
+ The CLI signs in separately from the dashboard \u2014 check with \`naro whoami\`.
4057
+ Wrong account? naro logout && naro login
4058
+ Project gone? remove .naro, then naro deploy`,
4059
+ { cause: error }
4060
+ );
4061
+ }
4062
+ const match = teams.find((team) => team.id === project.teamId);
4063
+ if (match) return match;
4064
+ }
4065
+ return resolveTeam(context, teams, deps);
3841
4066
  }
3842
- function keyword(text, at) {
3843
- const word = text.toLowerCase();
3844
- if (word === "null") return null;
3845
- if (word === "true") return true;
3846
- if (word === "false") return false;
3847
- return refuse(
3848
- `needs null, true or false after is at character ${String(at + 1)}`
3849
- );
4067
+ function scopeDeps(context, canPrompt) {
4068
+ return {
4069
+ canPrompt: () => canPrompt,
4070
+ accountLabel: () => accountLabel(context),
4071
+ switchAccount: async () => {
4072
+ clearSession();
4073
+ await runLogin(context);
4074
+ },
4075
+ listTeams: async () => (await context.client()).teams.list()
4076
+ };
3850
4077
  }
3851
- function attempt(parse) {
4078
+ async function accountLabel(context) {
3852
4079
  try {
3853
- return { ok: true, node: parse() };
3854
- } catch (error) {
3855
- if (error instanceof Refusal) return { ok: false, reason: error.message };
3856
- throw error;
4080
+ const { user } = await context.auth();
4081
+ if (!user) return "this account";
4082
+ const name = user.email ?? user.id;
4083
+ return user.github ? `${name} (${user.github})` : name;
4084
+ } catch {
4085
+ return "this account";
3857
4086
  }
3858
4087
  }
3859
- function parseLogic(text, kind, options = {}) {
3860
- if (typeof text !== "string")
3861
- return { ok: false, reason: "must be a filter string or a function" };
3862
- if (text.trim() === "") return { ok: false, reason: "must not be empty" };
3863
- return attempt(() => {
3864
- if (options.group === false) {
3865
- const alone = items(text, options.tokens, 0);
3866
- if (alone.length === 1 && alone[0]) return alone[0];
3867
- }
3868
- const nodes = items(text, options.tokens, 1);
3869
- return kind === "or" ? { or: nodes } : { and: nodes };
3870
- });
4088
+
4089
+ // src/commands/deployments.ts
4090
+ function shortSha(sha) {
4091
+ return sha ? sha.slice(0, 7) : "-";
3871
4092
  }
3872
- function items(text, tokens, level) {
3873
- const parser = new Parser(text, tokens);
3874
- const nodes = parser.list(level);
3875
- if (parser.at() === ")") refuse(`has an unmatched ")" ${parser.where()}`);
3876
- return nodes;
4093
+ function formatDeploymentLine(deployment) {
4094
+ return [
4095
+ deployment.id.padEnd(38),
4096
+ deployment.status.padEnd(10),
4097
+ deployment.branch.padEnd(16),
4098
+ shortSha(deployment.gitCommitSha).padEnd(9),
4099
+ deployment.url ?? ""
4100
+ ].join(" ");
4101
+ }
4102
+ async function runDeployments(context) {
4103
+ const client = await context.client();
4104
+ const projectId = await context.resolveProjectId();
4105
+ const deployments = await client.deployments.list(projectId);
4106
+ context.output.result({ projectId, deployments }, [
4107
+ `Deployments for ${projectId}:`,
4108
+ "",
4109
+ [
4110
+ "ID".padEnd(38),
4111
+ "STATUS".padEnd(10),
4112
+ "BRANCH".padEnd(16),
4113
+ "COMMIT".padEnd(9),
4114
+ "URL"
4115
+ ].join(" "),
4116
+ ...deployments.map(formatDeploymentLine)
4117
+ ]);
4118
+ }
4119
+ async function runInspect(context) {
4120
+ const [deploymentId] = context.positionalOrThrow(
4121
+ 1,
4122
+ "Usage: naro inspect <deployment-id>"
4123
+ );
4124
+ const client = await context.client();
4125
+ const detail = await client.deployments.get(deploymentId);
4126
+ const { deployment } = detail;
4127
+ const lastLogs = detail.logs.slice(-15);
4128
+ context.output.result({ deployment, logs: detail.logs }, [
4129
+ `Deployment ${deployment.id}`,
4130
+ ` status: ${deployment.status}${deployment.errorCode ? ` (${deployment.errorCode})` : ""}`,
4131
+ ` branch: ${deployment.branch}`,
4132
+ ` commit: ${deployment.gitCommitSha ?? "-"}`,
4133
+ ` url: ${deployment.url ?? "-"}`,
4134
+ ` created: ${deployment.createdAt}`,
4135
+ ` finished: ${deployment.finishedAt ?? "-"}`,
4136
+ ...deployment.errorMessage ? [` error: ${deployment.errorMessage}`] : [],
4137
+ ...lastLogs.length > 0 ? [
4138
+ "",
4139
+ "Recent build logs:",
4140
+ ...lastLogs.map((log) => ` [${log.stage}] ${log.message}`)
4141
+ ] : []
4142
+ ]);
3877
4143
  }
3878
4144
 
3879
- // src/commands/policy.ts
3880
- 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>";
3881
- var REMOVE_USAGE = "Usage: naro policy remove <table>";
3882
- var RULE_FLAGS = {
3883
- select: "selectRule",
3884
- insert: "insertRule",
3885
- update: "updateRule",
3886
- delete: "deleteRule"
3887
- };
3888
- var WHEN_FLAGS = {
3889
- select: { key: "selectWhen", tree: "using" },
3890
- insert: { key: "insertWhen", tree: "check" },
3891
- update: { key: "updateWhen", tree: "using" },
3892
- delete: { key: "deleteWhen", tree: "using" }
4145
+ // src/commands/domains.ts
4146
+ var defaults3 = {
4147
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
4148
+ now: () => Date.now()
3893
4149
  };
3894
- var AUTH_UID = "auth.uid()";
3895
- var OTHER_FLAGS = [
3896
- "selectRule",
3897
- "insertRule",
3898
- "updateRule",
3899
- "deleteRule",
3900
- "ownerColumn",
3901
- "selectWhen",
3902
- "insertWhen",
3903
- "updateWhen",
3904
- "updateCheck",
3905
- "deleteWhen",
3906
- "signedIn"
3907
- ];
3908
- function isRule(value) {
3909
- return POLICY_RULES.includes(value);
4150
+ var FIRST_DELAY_MS = 5e3;
4151
+ var MAX_DELAY_MS = 3e4;
4152
+ var WAIT_TIMEOUT_MS = 10 * 6e4;
4153
+ function isReady(domain) {
4154
+ return domain.ready ?? (domain.status === "active" && domain.sslStatus === "active");
3910
4155
  }
3911
- function refuse2(message) {
3912
- throw new NaroError("VALIDATION_FAILED", message);
4156
+ function isTerminal(domain) {
4157
+ return domain.ownership === "deleted" || domain.status === "blocked" || domain.status === "moved" || domain.status === "deleted";
3913
4158
  }
3914
- function flagName(key) {
3915
- return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
4159
+ var AXIS_DONE = /* @__PURE__ */ new Set(["verified", "active", "live"]);
4160
+ var AXIS_FAILED = /* @__PURE__ */ new Set(["failed", "deleted"]);
4161
+ function axisMark(value) {
4162
+ if (AXIS_DONE.has(value)) return "\u2713";
4163
+ if (AXIS_FAILED.has(value)) return "\u2717";
4164
+ return "\xB7";
3916
4165
  }
3917
- function conditionTree(flag, text) {
3918
- const parsed = parseLogic(text, "and", {
3919
- tokens: { [AUTH_UID]: { auth: "uid" } },
3920
- group: false
3921
- });
3922
- if (!parsed.ok) refuse2(`${flag} ${parsed.reason}.`);
3923
- const tree = parsed.node;
3924
- const checked = parsePolicyNode(tree, "");
3925
- if (!checked.ok) {
3926
- const path = checked.path.replace(/^\./, "");
3927
- refuse2(
3928
- path === "" ? `${flag} ${checked.reason}.` : `${flag}: ${path} ${checked.reason}.`
3929
- );
3930
- }
3931
- return tree;
4166
+ function axisLines(domain) {
4167
+ const axes = [
4168
+ ["ownership", domain.ownership],
4169
+ ["certificate", domain.certificate],
4170
+ ["routing", domain.routing]
4171
+ ];
4172
+ return axes.filter((axis) => axis[1] !== void 0).map(
4173
+ ([name, value]) => ` ${`${name}:`.padEnd(13)}${axisMark(value)} ${value}`
4174
+ );
3932
4175
  }
3933
- function conditionsFrom(flags) {
3934
- const conditions = {};
3935
- for (const action of POLICY_ACTIONS) {
3936
- const { key, tree } = WHEN_FLAGS[action];
3937
- const text = flags[key];
3938
- if (text === void 0) continue;
3939
- const flag = flagName(key);
3940
- if (flags[RULE_FLAGS[action]] !== void 0)
3941
- refuse2(`--${action} and ${flag} cannot be used together.`);
3942
- const condition = { [tree]: conditionTree(flag, text) };
3943
- if (action === "update" && flags.updateCheck !== void 0)
3944
- condition.check = conditionTree("--update-check", flags.updateCheck);
3945
- if (flags.signedIn) condition.role = "authenticated";
3946
- conditions[action] = condition;
3947
- }
3948
- if (flags.updateCheck !== void 0 && flags.updateWhen === void 0)
3949
- refuse2("--update-check needs --update-when.");
3950
- if (flags.signedIn && Object.keys(conditions).length === 0)
3951
- refuse2(
3952
- "--signed-in needs --select-when, --insert-when, --update-when or --delete-when."
3953
- );
3954
- return conditions;
4176
+ function axisSummary(domain) {
4177
+ const axes = [
4178
+ domain.ownership,
4179
+ domain.certificate,
4180
+ domain.routing
4181
+ ].filter((value) => value !== void 0);
4182
+ return axes.length > 0 ? axes.join(" \xB7 ") : `${domain.status} \xB7 ssl ${domain.sslStatus}`;
3955
4183
  }
3956
- function patchFromFile(context, path, table) {
3957
- let text;
3958
- try {
3959
- text = readFileSync3(resolve4(context.cwd, path), "utf8");
3960
- } catch {
3961
- refuse2(`Could not read ${path}.`);
3962
- }
3963
- let body;
3964
- try {
3965
- body = JSON.parse(text);
3966
- } catch {
3967
- refuse2(`${path} is not JSON.`);
3968
- }
3969
- if (typeof body !== "object" || body === null || Array.isArray(body))
3970
- refuse2(`${path} must hold a JSON object.`);
3971
- const { table: named, ...patch } = body;
3972
- if (Object.hasOwn(body, "table")) {
3973
- if (typeof named !== "string")
3974
- refuse2(`${path}: table must be a string, the table's name.`);
3975
- if (table !== void 0 && asciiLower(named) !== asciiLower(table))
3976
- refuse2(
3977
- `${path} is the policy of ${printable(named)}, not ${printable(table)}. Remove its "table" to set it on ${printable(table)}.`
4184
+ function recordLines(domain) {
4185
+ const instructions = domain.instructions ?? [];
4186
+ if (instructions.length > 0) {
4187
+ const lines2 = [" Required DNS records:"];
4188
+ for (const instruction of instructions) {
4189
+ lines2.push(
4190
+ ` ${instruction.type.padEnd(5)} ${instruction.name}`,
4191
+ ` host: ${instruction.host.padEnd(24)} value: ${instruction.value}`
3978
4192
  );
4193
+ if (instruction.note) lines2.push(` note: ${instruction.note}`);
4194
+ }
4195
+ return lines2;
3979
4196
  }
3980
- const allowed = /* @__PURE__ */ new Set([...POLICY_ACTIONS, "ownerColumn"]);
3981
- if (Object.keys(patch).some((key) => !allowed.has(key)))
3982
- refuse2(
3983
- `${path}: Only table, select, insert, update, delete and ownerColumn can be set.`
4197
+ if (domain.verification.length === 0) return [];
4198
+ const lines = [" Required DNS records:"];
4199
+ for (const record of domain.verification) {
4200
+ lines.push(
4201
+ ` Type: ${record.type.toUpperCase().padEnd(6)} Name: ${record.name}`,
4202
+ ` Value: ${record.value}`
3984
4203
  );
3985
- return patch;
3986
- }
3987
- function asciiLower(text) {
3988
- return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
3989
- }
3990
- function policyPatch(flags, context, table) {
3991
- if (flags.fromFile !== void 0) {
3992
- if (OTHER_FLAGS.some(
3993
- (key) => flags[key] !== void 0 && flags[key] !== false
3994
- ))
3995
- refuse2(
3996
- "--from-file takes the whole policy: leave out the other policy options."
3997
- );
3998
- if (!context) refuse2(SET_USAGE);
3999
- return patchFromFile(context, flags.fromFile, table);
4000
4204
  }
4001
- const patch = {};
4002
- for (const action of POLICY_ACTIONS) {
4003
- const value = flags[RULE_FLAGS[action]];
4004
- if (value === void 0) continue;
4005
- if (!isRule(value))
4006
- refuse2(`--${action} must be one of ${POLICY_RULES.join(", ")}.`);
4007
- patch[action] = value;
4205
+ return lines;
4206
+ }
4207
+ function domainLines(domain) {
4208
+ const lines = [
4209
+ ` ${domain.hostname.padEnd(36)} ${domain.status.padEnd(12)} ssl: ${domain.sslStatus}`,
4210
+ ...axisLines(domain)
4211
+ ];
4212
+ if (domain.redirectTo) {
4213
+ const status = domain.redirectStatus ?? 308;
4214
+ lines.push(` redirects to ${domain.redirectTo} (${String(status)})`);
4008
4215
  }
4009
- Object.assign(patch, conditionsFrom(flags));
4010
- if (flags.ownerColumn !== void 0) {
4011
- if (flags.ownerColumn.trim() === "") refuse2("--owner must name a column.");
4012
- patch.ownerColumn = flags.ownerColumn;
4216
+ if (domain.cause) {
4217
+ lines.push(` \u2717 ${domain.cause.message}`);
4218
+ if (domain.cause.fix) lines.push(` \u2192 ${domain.cause.fix}`);
4013
4219
  }
4014
- if (Object.keys(patch).length === 0)
4015
- refuse2(
4016
- `Set at least one of --select, --insert, --update, --delete, --owner, --select-when, --insert-when, --update-when, --delete-when or --from-file. ${SET_USAGE}`
4220
+ if (isReady(domain)) return lines;
4221
+ lines.push(...recordLines(domain));
4222
+ const guidance = domain.guidance;
4223
+ if (guidance && guidance.notes.length > 0) {
4224
+ lines.push(
4225
+ guidance.apex ? ` Apex domain (${domain.hostname}):` : " Note:"
4017
4226
  );
4018
- return patch;
4019
- }
4020
- var SHORT = {
4021
- contains: "cs",
4022
- containedBy: "cd",
4023
- overlaps: "ov"
4024
- };
4025
- function isAuthUid(value) {
4026
- return typeof value === "object" && value !== null && value.auth === "uid";
4027
- }
4028
- function nearAuthUid(text) {
4029
- return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "") === "auth.uid";
4030
- }
4031
- var DECIMAL2 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
4032
- function readsAsNumber(value) {
4033
- const text = String(value);
4034
- if (!DECIMAL2.test(text) || !Object.is(Number(text), value)) return false;
4035
- return text.includes(".") || Number.isSafeInteger(value);
4036
- }
4037
- function stringText(value, slot) {
4038
- if (slot === "is") return void 0;
4039
- if (slot === "like" && value.includes("*")) return void 0;
4040
- const bare = value !== "" && value.trim() === value && !/[,()"{}\\]/.test(value) && !/^(?:true|false|null)$/i.test(value) && !nearAuthUid(value) && !(slot === "array" && DECIMAL2.test(value));
4041
- return bare ? value : quoteFilterValue(value);
4227
+ for (const note of guidance.notes) lines.push(` ${note}`);
4228
+ }
4229
+ return lines;
4042
4230
  }
4043
- function valueText(value, slot) {
4044
- if (isAuthUid(value))
4045
- return slot === "like" || slot === "is" ? void 0 : AUTH_UID;
4046
- if (value === null) return slot === "is" ? "null" : void 0;
4047
- if (typeof value === "boolean")
4048
- return slot === "is" || slot === "eq" || slot === "in" ? String(value) : void 0;
4049
- if (typeof value === "number")
4050
- return slot === "array" && readsAsNumber(value) ? String(value) : void 0;
4051
- if (typeof value === "string") return stringText(value, slot);
4052
- return void 0;
4231
+ async function runDomainsList(context) {
4232
+ const client = await context.client();
4233
+ const projectId = await context.resolveProjectId();
4234
+ const domains = await client.domains.list(projectId);
4235
+ const pending = domains.filter((domain) => !isReady(domain));
4236
+ context.output.result(
4237
+ { projectId, domains },
4238
+ domains.length === 0 ? ["No custom domains. Add one with `naro domains add example.com`."] : [
4239
+ `Domains (${String(domains.length)}):`,
4240
+ ...domains.flatMap(domainLines),
4241
+ // list reads our stored copy; only verify goes and looks again.
4242
+ ...pending.length > 0 ? ["", "Re-check DNS now: naro domains verify"] : []
4243
+ ]
4244
+ );
4053
4245
  }
4054
- function columnText(name) {
4055
- if (name === "") return void 0;
4056
- return /[.,()"\s]/.test(name) ? quoteFilterValue(name) : name;
4246
+ async function runDomainsAdd(context) {
4247
+ const [hostname] = context.positionalOrThrow(
4248
+ 1,
4249
+ "Usage: naro domains add <hostname> [--no-www]"
4250
+ );
4251
+ const client = await context.client();
4252
+ const projectId = await context.resolveProjectId();
4253
+ const withWww = context.flags.noWww !== true;
4254
+ const domain = await client.domains.add(projectId, hostname, {
4255
+ withWww
4256
+ });
4257
+ const companion = domain.companion;
4258
+ const apex = domain.apex ?? domain.guidance?.apex ?? false;
4259
+ const alternative = domain.guidance?.alternative ?? `www.${domain.hostname}`;
4260
+ context.output.result(
4261
+ { projectId, domain, withWww, ...companion ? { companion } : {} },
4262
+ [
4263
+ `\u2705 Domain added: ${domain.hostname}`,
4264
+ "",
4265
+ ...domainLines(domain),
4266
+ ...companion ? [
4267
+ "",
4268
+ ` Also registered ${companion.hostname} \u2014 an apex domain often cannot hold`,
4269
+ " the CNAME above, and this form always can.",
4270
+ "",
4271
+ ...domainLines(companion)
4272
+ ] : [],
4273
+ ...!companion && apex && !withWww ? [
4274
+ "",
4275
+ ` --no-www: ${domain.hostname} was registered on its own. If your registrar`,
4276
+ ` refuses the record above, run \`naro domains add ${alternative}\`.`
4277
+ ] : [],
4278
+ "",
4279
+ isReady(domain) ? "The domain is active." : "Status: Pending verification \u2014 add the DNS records above, then run `naro domains verify`."
4280
+ ]
4281
+ );
4057
4282
  }
4058
- function slotOf(operator) {
4059
- if (operator === "eq" || operator === "neq") return "eq";
4060
- if (operator === "like" || operator === "ilike") return "like";
4061
- if (operator === "is") return "is";
4062
- return "ordered";
4283
+ async function verifyAll(client, projectId, hostname, auto = false) {
4284
+ if (hostname !== void 0)
4285
+ return [
4286
+ await client.domains.verifyByHostname(projectId, hostname, { auto })
4287
+ ];
4288
+ const listed = await client.domains.list(projectId);
4289
+ const verified = [];
4290
+ for (const domain of listed)
4291
+ verified.push(await client.domains.verify(projectId, domain.id, { auto }));
4292
+ return verified;
4063
4293
  }
4064
- function leafText(leaf, negated) {
4065
- const column = columnText(leaf.column);
4066
- if (column === void 0) return void 0;
4067
- const prefix = `${column}.${negated ? "not." : ""}`;
4068
- const short = SHORT[leaf.operator];
4069
- if (leaf.operator === "in" || short !== void 0) {
4070
- if (!Array.isArray(leaf.value)) return void 0;
4071
- const slot = short === void 0 ? "in" : "array";
4072
- const items2 = leaf.value.map((item) => valueText(item, slot));
4073
- if (items2.some((item) => item === void 0)) return void 0;
4074
- const list = items2.join(",");
4075
- return slot === "in" ? `${prefix}in.(${list})` : `${prefix}${String(short)}.{${list}}`;
4294
+ async function runDomainsVerify(context, overrides = {}) {
4295
+ const deps = { ...defaults3, ...overrides };
4296
+ const client = await context.client();
4297
+ const projectId = await context.resolveProjectId();
4298
+ const hostname = context.positionals[0];
4299
+ const wait = context.flags.wait === true;
4300
+ const progress = (line) => {
4301
+ if (!context.flags.json) context.output.log(line);
4302
+ };
4303
+ let domains = await verifyAll(client, projectId, hostname);
4304
+ if (domains.length === 0) {
4305
+ context.output.result({ projectId, domains, ready: false }, [
4306
+ "No custom domains. Add one with `naro domains add example.com`."
4307
+ ]);
4308
+ return;
4076
4309
  }
4077
- if (Array.isArray(leaf.value)) return void 0;
4078
- const text = valueText(leaf.value, slotOf(leaf.operator));
4079
- return text === void 0 ? void 0 : `${prefix}${leaf.operator}.${text}`;
4080
- }
4081
- function groupText(kind, children) {
4082
- const texts = children.map((child) => filterText(child, false));
4083
- if (texts.length === 0 || texts.some((text) => text === void 0))
4084
- return void 0;
4085
- return `${kind}(${texts.join(",")})`;
4086
- }
4087
- function filterText(node, top = true) {
4088
- if ("operator" in node) return leafText(node, false);
4089
- if ("not" in node) {
4090
- const inner = node.not;
4091
- if ("operator" in inner) return leafText(inner, true);
4092
- if ("or" in inner) return negatedText(groupText("or", inner.or));
4093
- if ("and" in inner) return negatedText(groupText("and", inner.and));
4094
- return void 0;
4310
+ let timedOut = false;
4311
+ let abandoned = false;
4312
+ if (wait && !domains.every(isReady)) {
4313
+ const deadline = deps.now() + WAIT_TIMEOUT_MS;
4314
+ let delay = FIRST_DELAY_MS;
4315
+ for (; ; ) {
4316
+ const pending = domains.filter((domain) => !isReady(domain));
4317
+ if (pending.length > 0 && pending.every(isTerminal)) {
4318
+ abandoned = true;
4319
+ break;
4320
+ }
4321
+ const remaining = deadline - deps.now();
4322
+ if (remaining <= 0) {
4323
+ timedOut = true;
4324
+ break;
4325
+ }
4326
+ for (const domain of pending)
4327
+ progress(
4328
+ isTerminal(domain) ? `\u26A0 ${domain.hostname}: ${axisSummary(domain)} \u2014 waiting cannot fix this one` : `\u25B8 ${domain.hostname}: ${axisSummary(domain)}`
4329
+ );
4330
+ await deps.sleep(Math.min(delay, remaining));
4331
+ delay = Math.min(delay * 2, MAX_DELAY_MS);
4332
+ domains = await verifyAll(client, projectId, hostname, true);
4333
+ if (domains.every(isReady)) break;
4334
+ }
4095
4335
  }
4096
- if ("or" in node) return groupText("or", node.or);
4097
- if (top && node.and.length > 1) {
4098
- const texts = node.and.map((child) => filterText(child, false));
4099
- return texts.some((text) => text === void 0) ? void 0 : texts.join(",");
4336
+ const ready = domains.every(isReady);
4337
+ const body = domains.flatMap(domainLines);
4338
+ if (!ready) {
4339
+ for (const line of body) progress(line);
4340
+ const notReady = domains.filter((domain) => !isReady(domain));
4341
+ const names = notReady.map((domain) => domain.hostname).join(", ");
4342
+ const details = { projectId, domains, ready: false };
4343
+ if (timedOut) {
4344
+ const cause2 = notReady[0]?.cause;
4345
+ throw new NaroError(
4346
+ "TIMEOUT",
4347
+ `${names} did not go live within 10 minutes.${cause2 ? ` ${cause2.message}` : ""} DNS changes can take longer to propagate \u2014 re-run \`naro domains verify\` later.`,
4348
+ { details }
4349
+ );
4350
+ }
4351
+ if (abandoned) {
4352
+ const blocked = domains.find(isTerminal);
4353
+ throw new NaroError(
4354
+ blocked?.cause?.code ?? "DOMAIN_VERIFICATION_FAILED",
4355
+ blocked?.cause?.message ?? `${names} cannot be verified (status: ${blocked?.status ?? "unknown"}). Remove it and add it again.`,
4356
+ { details }
4357
+ );
4358
+ }
4359
+ const cause = notReady[0]?.cause;
4360
+ throw new NaroError(
4361
+ cause?.code ?? "DOMAIN_NOT_READY",
4362
+ `${names} is not live yet.${cause ? ` ${cause.message}` : ""} Add the records above, then run \`naro domains verify --wait\`.`,
4363
+ { details }
4364
+ );
4100
4365
  }
4101
- return groupText("and", node.and);
4366
+ context.output.result({ projectId, domains, ready }, [
4367
+ ...body,
4368
+ "",
4369
+ domains.length === 1 ? `\u2705 ${domains[0]?.hostname ?? ""} is live.` : "\u2705 Every domain is live."
4370
+ ]);
4102
4371
  }
4103
- function negatedText(text) {
4104
- return text === void 0 ? void 0 : `not.${text}`;
4372
+ async function runDomainsRemove(context) {
4373
+ const [hostname] = context.positionalOrThrow(
4374
+ 1,
4375
+ "Usage: naro domains remove <hostname>"
4376
+ );
4377
+ await confirmOrAbort(
4378
+ context,
4379
+ `Remove ${String(hostname)}? Traffic to it will stop routing here.`
4380
+ );
4381
+ const client = await context.client();
4382
+ const projectId = await context.resolveProjectId();
4383
+ await client.domains.remove(projectId, hostname);
4384
+ context.output.result({ projectId, hostname, removed: true }, [
4385
+ `\u2705 Removed ${String(hostname)}`
4386
+ ]);
4105
4387
  }
4106
- function isInvalid(value) {
4107
- return typeof value === "object" && "invalid" in value;
4388
+
4389
+ // src/commands/env.ts
4390
+ import { existsSync, writeFileSync as writeFileSync4 } from "fs";
4391
+ import { join as join5 } from "path";
4392
+ var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
4393
+ var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4394
+ function parseTarget(value) {
4395
+ if (value === void 0) return "all";
4396
+ if (!TARGETS.has(value))
4397
+ throw new NaroError(
4398
+ "VALIDATION_FAILED",
4399
+ "Environment must be production, preview or all."
4400
+ );
4401
+ return value;
4108
4402
  }
4109
- function cellOf(value) {
4110
- if (typeof value === "string") return value;
4111
- return isInvalid(value) ? "invalid" : "condition";
4403
+ function maskedValue(row) {
4404
+ if (!row.isSecret) return row.value ?? "";
4405
+ return "\u2022\u2022\u2022\u2022\u2022\u2022 (secret)";
4112
4406
  }
4113
- function conditionText(action, value) {
4114
- if (typeof value === "string") return value;
4115
- if (isInvalid(value))
4116
- return `cannot be read (${value.invalid}); it denies everyone but the service key`;
4117
- const using = value.using ? filterText(value.using) : "";
4118
- const check = value.check ? filterText(value.check) : "";
4119
- if (using === void 0 || check === void 0)
4120
- return `as JSON ${JSON.stringify(value)}`;
4121
- const parts = [];
4122
- if (using !== "") parts.push(`using ${using}`);
4123
- if (check !== "") parts.push(`check ${check}`);
4124
- else if (action === "update") parts.push("check = using");
4125
- const text = parts.join("; ");
4126
- return value.role === "authenticated" ? `${text} (signed-in users only)` : text;
4407
+ async function runEnvList(context) {
4408
+ const client = await context.client();
4409
+ const projectId = await context.resolveProjectId();
4410
+ const reveal = context.flags.reveal;
4411
+ const rows = await client.env.list(projectId, { reveal });
4412
+ context.output.result(
4413
+ {
4414
+ projectId,
4415
+ envVars: rows.map((row) => ({
4416
+ id: row.id,
4417
+ key: row.key,
4418
+ target: row.target,
4419
+ isSecret: row.isSecret,
4420
+ value: row.isSecret && !reveal ? null : row.value
4421
+ }))
4422
+ },
4423
+ [
4424
+ `Environment variables (${String(rows.length)}):`,
4425
+ ...rows.map(
4426
+ (row) => ` ${row.key.padEnd(32)} ${row.target.padEnd(12)} ${reveal ? row.value ?? "" : maskedValue(row)}`
4427
+ )
4428
+ ]
4429
+ );
4127
4430
  }
4128
- function policyTable(policies) {
4129
- const header = ["Table", ...POLICY_ACTIONS, "Owner column"];
4130
- const rows = policies.map((policy) => [
4131
- printable(policy.table),
4132
- ...POLICY_ACTIONS.map((action) => cellOf(policy[action])),
4133
- policy.ownerColumn === null ? "-" : printable(policy.ownerColumn)
4431
+ async function runEnvAdd(context) {
4432
+ context.positionalOrThrow(
4433
+ 1,
4434
+ "Usage: naro env add KEY [production|preview|all]"
4435
+ );
4436
+ const [key, targetArg] = context.positionals;
4437
+ if (!key || !KEY_RE.test(key) || key.length > 256)
4438
+ throw new NaroError(
4439
+ "VALIDATION_FAILED",
4440
+ "Keys must look like MY_VARIABLE (letters, digits, underscore)."
4441
+ );
4442
+ const target = parseTarget(targetArg);
4443
+ let value;
4444
+ if (!process.stdin.isTTY) {
4445
+ value = await readStdin();
4446
+ } else {
4447
+ value = await context.prompter.askHidden(`? What's the value of ${key}? `);
4448
+ }
4449
+ if (value.length === 0)
4450
+ throw new NaroError("VALIDATION_FAILED", `No value provided for ${key}.`);
4451
+ const isSecret = !key.startsWith("NEXT_PUBLIC_");
4452
+ const client = await context.client();
4453
+ const projectId = await context.resolveProjectId();
4454
+ await client.env.set(projectId, [{ key, value, target, isSecret }]);
4455
+ context.output.result({ projectId, key, target, isSecret }, [
4456
+ `\u2705 Added ${key} (${target}${isSecret ? ", secret" : ""})`
4134
4457
  ]);
4135
- const widths = header.map(
4136
- (title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0))
4137
- );
4138
- const table = [header, ...rows].map(
4139
- (cells) => cells.map(
4140
- (cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)
4141
- ).join(" ").trimEnd()
4458
+ }
4459
+ async function runEnvRemove(context) {
4460
+ context.positionalOrThrow(
4461
+ 1,
4462
+ "Usage: naro env remove KEY [production|preview|all]"
4142
4463
  );
4143
- const lines = policies.flatMap(
4144
- (policy) => POLICY_ACTIONS.filter((action) => typeof policy[action] !== "string").map(
4145
- (action) => ({
4146
- label: `${printable(policy.table)}.${action}`,
4147
- text: printable(conditionText(action, policy[action]))
4148
- })
4149
- )
4464
+ const [key, targetArg] = context.positionals;
4465
+ const target = targetArg === void 0 ? void 0 : parseTarget(targetArg);
4466
+ await confirmOrAbort(
4467
+ context,
4468
+ `Remove ${String(key)}${target ? ` (${target})` : ""} from this project?`
4150
4469
  );
4151
- if (lines.length === 0) return table;
4152
- const width = Math.max(...lines.map((line) => line.label.length));
4153
- return [
4154
- ...table,
4155
- "",
4156
- ...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
4157
- ];
4470
+ const client = await context.client();
4471
+ const projectId = await context.resolveProjectId();
4472
+ const removed = await client.env.remove(projectId, key, target);
4473
+ context.output.result({ projectId, key, removed }, [
4474
+ `\u2705 Removed ${String(key)} (${String(removed)} entr${removed === 1 ? "y" : "ies"})`
4475
+ ]);
4158
4476
  }
4159
- async function runPolicyList(context) {
4477
+ async function runEnvPull(context) {
4478
+ const file = context.positionals[0] ?? ".env.local";
4479
+ const surface = context.flags.env === "production" ? "production" : "preview";
4480
+ if (context.flags.env && !["production", "preview"].includes(context.flags.env))
4481
+ throw new NaroError(
4482
+ "VALIDATION_FAILED",
4483
+ "--env must be production or preview."
4484
+ );
4160
4485
  const client = await context.client();
4161
4486
  const projectId = await context.resolveProjectId();
4162
- const policies = await client.backend.policies(projectId);
4487
+ const rows = await client.env.list(projectId, { reveal: true });
4488
+ const selected = rows.filter(
4489
+ (row) => row.target === "all" || row.target === surface
4490
+ );
4491
+ const vars = Object.fromEntries(
4492
+ selected.map((row) => [row.key, row.value ?? ""])
4493
+ );
4494
+ const path = join5(context.cwd, file);
4495
+ if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
4496
+ const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
4497
+ if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
4498
+ }
4499
+ writeFileSync4(
4500
+ path,
4501
+ `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
4502
+ ${serializeDotenv(vars)}`,
4503
+ { mode: 384 }
4504
+ );
4163
4505
  context.output.result(
4164
- { projectId, policies },
4165
- policies.length === 0 ? [
4166
- "No policies yet: every table denies anon and signed-in requests.",
4167
- "Open one with `naro policy set <table> --select public`."
4168
- ] : [
4169
- ...policyTable(policies),
4170
- "",
4171
- "Tables not listed deny anon and signed-in requests. The service key skips policies."
4172
- ]
4506
+ { projectId, file, environment: surface, count: selected.length },
4507
+ [`\u2705 Pulled ${String(selected.length)} variable(s) into ${file}`]
4173
4508
  );
4174
4509
  }
4175
- async function runPolicySet(context) {
4176
- const [table] = context.positionalOrThrow(1, SET_USAGE);
4177
- const patch = policyPatch(context.flags, context, table);
4510
+
4511
+ // src/commands/gen.ts
4512
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
4513
+ import { dirname as dirname2, resolve as resolve4 } from "path";
4514
+ var LANG_MESSAGE = "naro gen types writes TypeScript only. Pass --lang typescript, or leave --lang out.";
4515
+ async function runGenTypes(context) {
4516
+ const { lang } = context.flags;
4517
+ if (lang !== void 0 && lang !== "typescript")
4518
+ throw new NaroError("VALIDATION_FAILED", LANG_MESSAGE);
4178
4519
  const client = await context.client();
4179
4520
  const projectId = await context.resolveProjectId();
4180
- const policy = await client.backend.setPolicy(
4181
- projectId,
4182
- table,
4183
- patch
4521
+ const tables = await client.db.tables(projectId);
4522
+ const read = await policiesForTypes(client, projectId);
4523
+ const types = generateTypes(
4524
+ tables,
4525
+ read.policies === void 0 ? {} : { policies: read.policies }
4184
4526
  );
4185
- context.output.result({ projectId, policy }, [
4186
- `\u2705 Saved the policy for ${printable(policy.table)}. It takes effect within about 10 seconds.`,
4187
- ...policyTable([policy])
4527
+ const undescribed = tables.flatMap(
4528
+ (table) => table.error === void 0 ? [] : [{ name: table.name, error: table.error }]
4529
+ );
4530
+ for (const { name, error } of undescribed)
4531
+ context.output.warn(
4532
+ `\u26A0 ${printable(name)} could not be described and was generated as an empty interface: ${printable(error)}`
4533
+ );
4534
+ if (read.policies === void 0)
4535
+ context.output.warn(
4536
+ `\u26A0 The backend's policies could not be read (${printable(read.reason)}), so owner columns stay required in Insert.`
4537
+ );
4538
+ const extra = {
4539
+ ...undescribed.length > 0 ? { undescribed } : {},
4540
+ ...read.policies === void 0 ? { policies: "unavailable", policiesReason: read.reason } : {}
4541
+ };
4542
+ if (context.flags.output) {
4543
+ const path = resolve4(context.cwd, context.flags.output);
4544
+ mkdirSync4(dirname2(path), { recursive: true });
4545
+ writeFileSync5(path, types);
4546
+ context.output.result(
4547
+ {
4548
+ projectId,
4549
+ output: context.flags.output,
4550
+ tables: tables.length,
4551
+ ...extra
4552
+ },
4553
+ [
4554
+ `\u2705 Wrote types for ${String(tables.length)} table(s) to ${context.flags.output}`
4555
+ ]
4556
+ );
4557
+ return;
4558
+ }
4559
+ context.output.result({ projectId, types, ...extra }, [
4560
+ types.replace(/\n$/, "")
4188
4561
  ]);
4189
4562
  }
4190
- async function runPolicyRemove(context) {
4191
- const [table] = context.positionalOrThrow(1, REMOVE_USAGE);
4192
- await confirmOrAbort(
4193
- context,
4194
- `Remove the policy for ${printable(table)}? Anon and signed-in requests to it are denied from then on.`
4563
+
4564
+ // src/commands/logs.ts
4565
+ var FOLLOW_INTERVAL_MS = 3e3;
4566
+ function sleep(ms) {
4567
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
4568
+ }
4569
+ function formatBuildLog(log) {
4570
+ return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
4571
+ }
4572
+ function formatRuntimeLog(entry) {
4573
+ const time = new Date(entry.ts).toISOString();
4574
+ const request = entry.method && entry.path ? ` ${entry.method} ${entry.path}${entry.status ? ` ${String(entry.status)}` : ""}` : "";
4575
+ return `${time} [${entry.level}]${request} ${entry.message}`.trimEnd();
4576
+ }
4577
+ async function latestProductionDeploymentId(client, projectId) {
4578
+ const deployments = await client.deployments.list(projectId);
4579
+ const latest = deployments.find(
4580
+ (deployment) => deployment.status === "READY"
4581
+ );
4582
+ const fallback = deployments[0];
4583
+ const chosen = latest ?? fallback;
4584
+ if (!chosen)
4585
+ throw new NaroError(
4586
+ "DEPLOYMENT_NOT_FOUND",
4587
+ "This project has no deployments yet."
4588
+ );
4589
+ return chosen.id;
4590
+ }
4591
+ async function buildLogs(context, client, deploymentId) {
4592
+ if (!context.flags.follow) {
4593
+ const detail = await client.deployments.get(deploymentId);
4594
+ context.output.result(
4595
+ { deploymentId, type: "build", logs: detail.logs },
4596
+ detail.logs.map(formatBuildLog)
4597
+ );
4598
+ return;
4599
+ }
4600
+ let lastSequence = -1;
4601
+ for (; ; ) {
4602
+ const detail = await client.deployments.get(deploymentId);
4603
+ for (const log of detail.logs) {
4604
+ if (log.sequence > lastSequence) {
4605
+ lastSequence = log.sequence;
4606
+ context.output.log(formatBuildLog(log));
4607
+ }
4608
+ }
4609
+ if (isTerminalStatus(detail.deployment.status)) {
4610
+ context.output.result(
4611
+ {
4612
+ deploymentId,
4613
+ type: "build",
4614
+ status: detail.deployment.status,
4615
+ logs: detail.logs
4616
+ },
4617
+ [`\u2014 deployment ${detail.deployment.status} \u2014`]
4618
+ );
4619
+ return;
4620
+ }
4621
+ await sleep(FOLLOW_INTERVAL_MS);
4622
+ }
4623
+ }
4624
+ async function runtimeLogs(context, client, projectId) {
4625
+ const environment = context.flags.env === "preview" ? "preview" : "production";
4626
+ if (context.flags.env && !["production", "preview"].includes(context.flags.env))
4627
+ throw new NaroError(
4628
+ "VALIDATION_FAILED",
4629
+ "--env must be production or preview."
4630
+ );
4631
+ const options = {
4632
+ environment,
4633
+ ...context.flags.level ? { level: context.flags.level } : {}
4634
+ };
4635
+ if (!context.flags.follow) {
4636
+ const entries = await client.logs.runtime(projectId, options);
4637
+ context.output.result(
4638
+ { projectId, type: "runtime", environment, entries },
4639
+ entries.length > 0 ? entries.map(formatRuntimeLog) : ["(no runtime logs yet \u2014 the worker logs on each request)"]
4640
+ );
4641
+ return;
4642
+ }
4643
+ context.output.log(
4644
+ `Following ${environment} runtime logs (Ctrl-C to stop)...`
4195
4645
  );
4646
+ let lastTs = -1;
4647
+ let boundary = /* @__PURE__ */ new Set();
4648
+ for (; ; ) {
4649
+ const entries = await client.logs.runtime(projectId, options);
4650
+ for (const entry of entries) {
4651
+ if (entry.ts < lastTs) continue;
4652
+ const key = JSON.stringify(entry);
4653
+ if (entry.ts === lastTs) {
4654
+ if (boundary.has(key)) continue;
4655
+ } else {
4656
+ lastTs = entry.ts;
4657
+ boundary = /* @__PURE__ */ new Set();
4658
+ }
4659
+ boundary.add(key);
4660
+ context.output.log(formatRuntimeLog(entry));
4661
+ }
4662
+ await sleep(FOLLOW_INTERVAL_MS);
4663
+ }
4664
+ }
4665
+ async function runLogs(context) {
4196
4666
  const client = await context.client();
4197
- const projectId = await context.resolveProjectId();
4198
- const policy = await client.backend.removePolicy(projectId, table);
4199
- context.output.result({ projectId, removed: true, policy }, [
4200
- `\u2705 Removed the policy for ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.`
4201
- ]);
4667
+ const [deploymentId] = context.positionals;
4668
+ const type = context.flags.type ?? (deploymentId !== void 0 ? "build" : "runtime");
4669
+ if (type !== "build" && type !== "runtime")
4670
+ throw new NaroError(
4671
+ "VALIDATION_FAILED",
4672
+ "--type must be build or runtime."
4673
+ );
4674
+ if (type === "runtime") {
4675
+ if (deploymentId)
4676
+ throw new NaroError(
4677
+ "VALIDATION_FAILED",
4678
+ "Runtime logs are per project, not per deployment. Drop the deployment id or use --type build."
4679
+ );
4680
+ const projectId = await context.resolveProjectId();
4681
+ await runtimeLogs(context, client, projectId);
4682
+ return;
4683
+ }
4684
+ const target = deploymentId ?? await latestProductionDeploymentId(
4685
+ client,
4686
+ await context.resolveProjectId()
4687
+ );
4688
+ await buildLogs(context, client, target);
4202
4689
  }
4203
4690
 
4204
4691
  // src/commands/projects.ts
@@ -4366,16 +4853,21 @@ Usage:
4366
4853
  and today's writes against the daily write limit (UTC)
4367
4854
  naro db query <sql> | --file <f> Run SQL against it (reads stdin when piped)
4368
4855
  naro db push [--dry-run] Apply pending migrations from ./migrations (asks first; --yes to skip)
4856
+ --dry-run checks their policy statements on this machine and
4857
+ exits 1 on one the server would refuse
4369
4858
  naro db dump [--output <f>] Download a SQL dump of the database (stdout by default)
4370
4859
  [--schema-only|--data-only] [--table a,b] Cloudflare holds other queries until it finishes
4371
4860
  naro db restore --bookmark <id> Roll the database back (Time Travel, last 30 days; asks first)
4372
4861
  | --timestamp <time> [--dry-run] prints the command that undoes it
4373
4862
  naro db pull [--dir <d>] Write the remote schema as migrations/0001_remote_schema.sql
4374
4863
  and record it as applied - a baseline (asks first)
4375
- naro db reset [--dir <d>] Drop everything, clear the ledger, re-apply ./migrations
4376
- [--dry-run] [--yes] (asks first; db restore --bookmark undoes it)
4377
- naro db lint [--strict] Check the schema for what D1 actually trips on
4378
- (exits 1 on an error, --strict on a warning too)
4864
+ naro db reset [--dir <d>] Drop everything and clear the policies and the ledger, then re-apply
4865
+ [--dry-run] [--yes] ./migrations, which create the policies again. Refuses files the server
4866
+ would refuse; lists first the policies the files do not make
4867
+ (asks first; db restore --bookmark undoes it)
4868
+ naro db lint [--strict] Check the schema for what D1 actually trips on, and the RLS policies
4869
+ (rls_no_policy, rls_policy_invalid, rls_policy_text_default,
4870
+ rls_policy_unindexed; exits 1 on an error, --strict on a warning too)
4379
4871
  naro db table create <name> --column <spec> [--column <spec>\u2026]
4380
4872
  [--owner <column>] [--if-not-exists] Create a table without SQL. <spec> is name:type[:modifier\u2026]:
4381
4873
  types integer, real, text, blob, numeric, boolean, datetime, json;
@@ -4438,15 +4930,24 @@ Usage:
4438
4930
  works too, but lands in shell history
4439
4931
  naro backend users delete <id|username|email>
4440
4932
  Delete a user and end its sessions (asks first; --yes to skip)
4441
- naro policy list Who the Data API lets through, per table (no policy = deny)
4933
+ naro policy list [--table <t>] Row-level security policies as CREATE POLICY (no policy = deny)
4934
+ naro policy create "CREATE POLICY <name> ON <table> \u2026" | --file <policies.sql>
4935
+ Postgres's CREATE POLICY: AS PERMISSIVE|RESTRICTIVE,
4936
+ FOR ALL|SELECT|INSERT|UPDATE|DELETE, TO anon|authenticated|public,
4937
+ USING (\u2026), WITH CHECK (\u2026); a file may hold DROP POLICY too
4938
+ (run in order, stops at the first refusal)
4939
+ [--allow-unindexed] Accept a subquery on the outer row that no index serves
4940
+ (refused otherwise, naming the index to create)
4941
+ naro policy drop <name> --table <t> DROP POLICY (asks first)
4442
4942
  naro policy set <table> [--select <rule>] [--insert <rule>] [--update <rule>] [--delete <rule>]
4443
4943
  [--owner <column>] Rules: none | public | authenticated | owner (owner needs --owner)
4444
4944
  [--select-when <filter>] [--insert-when <filter>] [--update-when <filter>]
4445
4945
  [--update-check <filter>] [--delete-when <filter>] [--signed-in]
4446
4946
  Or a condition per action, in Supabase filter syntax;
4447
4947
  auth.uid() is the signed-in user's id
4448
- [--from-file <policy.json>] Or the whole policy as JSON
4449
- naro policy remove <table> Back to deny for anon and signed-in requests (asks first)
4948
+ [--from-file <policy.json>] Or the whole policy as JSON. Writes the policies naro_select,
4949
+ naro_insert, naro_update, naro_delete (shorthand for CREATE POLICY)
4950
+ naro policy remove <table> Drop every policy on it: back to deny (asks first)
4450
4951
 
4451
4952
  naro promote <deployment-id> Publish a build to production
4452
4953
  naro rollback [deployment-id] Roll production back to the previous build
@@ -4463,7 +4964,7 @@ Global options:
4463
4964
  --prod Deploy/target production
4464
4965
  --wait Poll until the domain goes live (naro domains verify)
4465
4966
  --no-www Register the apex alone (naro domains add)
4466
- --file <path> SQL file to run (naro db query)
4967
+ --file <path> SQL file to run (naro db query, naro policy create)
4467
4968
  --dry-run Show what would change without changing it (naro db push, db restore, db reset, db table, db index)
4468
4969
  --output <file> Write to a file instead of stdout (naro gen types, db dump)
4469
4970
  --lang <lang> Language of the types; typescript, the default, is the only one (naro gen types)
@@ -4471,7 +4972,8 @@ Global options:
4471
4972
  --strict Fail on warnings too, not only errors (naro db lint)
4472
4973
  --schema-only Dump the schema without rows (naro db dump)
4473
4974
  --data-only Dump rows without the schema (naro db dump)
4474
- --table <a,b> Dump only these tables, comma-separated (naro db dump)
4975
+ --table <a,b> Dump only these tables, comma-separated (naro db dump); the policy's table
4976
+ (naro policy list, naro policy drop)
4475
4977
  --bookmark <id> Restore point: a bookmark from db dump or db restore (naro db restore)
4476
4978
  --timestamp <time> Restore point: RFC 3339 with a timezone, or Unix seconds (naro db restore)
4477
4979
  --status <s> applied or reverted (naro migration repair)
@@ -4758,6 +5260,13 @@ function route(parsed) {
4758
5260
  return { handler: runPolicyList, positionals: positionals.slice(1) };
4759
5261
  case "set":
4760
5262
  return { handler: runPolicySet, positionals: positionals.slice(1) };
5263
+ case "create":
5264
+ return {
5265
+ handler: runPolicyCreate,
5266
+ positionals: positionals.slice(1)
5267
+ };
5268
+ case "drop":
5269
+ return { handler: runPolicyDrop, positionals: positionals.slice(1) };
4761
5270
  case "remove":
4762
5271
  case "rm":
4763
5272
  return {
@@ -4789,8 +5298,8 @@ function route(parsed) {
4789
5298
  function version() {
4790
5299
  try {
4791
5300
  const pkg = JSON.parse(
4792
- readFileSync4(
4793
- join5(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
5301
+ readFileSync5(
5302
+ join6(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
4794
5303
  "utf8"
4795
5304
  )
4796
5305
  );