@mediamonks/canopy-cli 0.5.1 → 0.7.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.
Files changed (43) hide show
  1. package/dist/{db-BW1ZolWn.mjs → db-BkUmbGgx.mjs} +71 -15
  2. package/dist/{deploy-alC2--pZ.mjs → deploy-BcczXlSQ.mjs} +106 -8
  3. package/dist/dev-CvtoGFW0.mjs +2 -0
  4. package/dist/{dev-CQQhMOoD.mjs → dev-DvyKHe2d.mjs} +44 -14
  5. package/dist/docs/ci.md +1 -3
  6. package/dist/docs/cli.md +3 -4
  7. package/dist/docs/data.md +5 -4
  8. package/dist/docs/deploy.md +16 -4
  9. package/dist/docs/dev.md +13 -11
  10. package/dist/docs/getting-started.md +1 -3
  11. package/dist/docs/infrastructure.md +27 -15
  12. package/dist/docs/migrations.md +183 -0
  13. package/dist/docs/porting-nextjs.md +232 -0
  14. package/dist/docs/sdk-ai.md +103 -0
  15. package/dist/docs/sdk-auth.md +57 -0
  16. package/dist/docs/sdk-database.md +126 -0
  17. package/dist/docs/sdk-files.md +82 -0
  18. package/dist/docs/sdk-services.md +58 -0
  19. package/dist/docs/sdk.md +113 -0
  20. package/dist/docs/staging.md +98 -0
  21. package/dist/docs/static-serving.md +4 -1
  22. package/dist/docs/troubleshooting.md +1 -3
  23. package/dist/{docs-DvSPpSIR.mjs → docs-2cUEug69.mjs} +23 -10
  24. package/dist/index.mjs +22 -20
  25. package/dist/{local-C5-GWJcx.mjs → local-C47tmhl7.mjs} +365 -41
  26. package/dist/{login-BO6abf40.mjs → login-ByD0hcxu.mjs} +5 -3
  27. package/dist/migrations-I1adj0z2.mjs +50 -0
  28. package/dist/{open-DnlYDqrU.mjs → open-DD9oa-Ho.mjs} +11 -4
  29. package/dist/{package-CHoZKzaw.mjs → package-CDB-uxy3.mjs} +1 -1
  30. package/dist/{projects-ijfe7Ffx.mjs → projects-CSH7KsPi.mjs} +17 -9
  31. package/dist/{site-BJjoePQS.mjs → site-BUlQR117.mjs} +14 -1
  32. package/dist/skill/canopy/SKILL.md +24 -1
  33. package/dist/{skill-CW0QSbAJ.mjs → skill-DrWzmUMv.mjs} +2 -2
  34. package/dist/staging-CfF2-pHs.mjs +113 -0
  35. package/dist/templates/spa/AGENTS.md +4 -0
  36. package/dist/templates/static/AGENTS.md +4 -0
  37. package/dist/templates/worker/AGENTS.md +7 -0
  38. package/dist/{token-BeF9zczg.mjs → token-nieJV1w7.mjs} +2 -2
  39. package/dist/{ui-DThlyHgo.mjs → ui-D8HxX0Ng.mjs} +3 -2
  40. package/dist/{upgrade-CGz1yzsW.mjs → upgrade-CH_jHkc7.mjs} +2 -2
  41. package/dist/{usage-DQpH7A3X.mjs → usage-qqPtkDtT.mjs} +2 -2
  42. package/package.json +2 -2
  43. package/dist/dev-BJ5xEwqW.mjs +0 -2
@@ -1,5 +1,5 @@
1
- import { o as apiDataRequest } from "./ui-DThlyHgo.mjs";
2
- import { n as rewriteApiPath } from "./dev-CQQhMOoD.mjs";
1
+ import { o as apiDataRequest } from "./ui-D8HxX0Ng.mjs";
2
+ import { n as rewriteApiPath } from "./dev-DvyKHe2d.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, relative, sep } from "node:path";
@@ -332,7 +332,7 @@ function dbRouter(config) {
332
332
  * than deep-merge, and a null value stores null instead of removing the
333
333
  * key.
334
334
  */
335
- const DOC_COLUMNS = "id, data, created_at, updated_at";
335
+ const DOC_COLUMNS$1 = "id, data, created_at, updated_at";
336
336
  const DOC_STORE_DDL_PG = [`CREATE TABLE IF NOT EXISTS _canopy_docs (
337
337
  collection TEXT NOT NULL,
338
338
  id TEXT NOT NULL,
@@ -348,7 +348,7 @@ const jsonAt = (column, pathParam) => `(${column}::jsonb #> string_to_array(trim
348
348
  * jsonb, so 5 matches 5 and false matches false; IS NOT DISTINCT FROM makes
349
349
  * null match JSON null (and absent fields, which extract to SQL NULL).
350
350
  */
351
- function fieldFilters(where, next) {
351
+ function fieldFilters$1(where, next) {
352
352
  const sql = [];
353
353
  const params = [];
354
354
  for (const [field, value] of Object.entries(where)) {
@@ -389,14 +389,14 @@ const postgresDialect = {
389
389
  },
390
390
  get(collection, id) {
391
391
  return {
392
- sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs WHERE collection = $1 AND id = $2`,
392
+ sql: `SELECT ${DOC_COLUMNS$1} FROM _canopy_docs WHERE collection = $1 AND id = $2`,
393
393
  params: [collection, id]
394
394
  };
395
395
  },
396
396
  find({ collection, where, orderBy, order, limit, offset }) {
397
397
  const next = counter();
398
398
  const collectionParam = next();
399
- const filters = fieldFilters(where, next);
399
+ const filters = fieldFilters$1(where, next);
400
400
  const params = [collection, ...filters.params];
401
401
  let orderExpr = "created_at";
402
402
  if (orderBy !== void 0) {
@@ -408,7 +408,7 @@ const postgresDialect = {
408
408
  }
409
409
  }
410
410
  return {
411
- sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs
411
+ sql: `SELECT ${DOC_COLUMNS$1} FROM _canopy_docs
412
412
  WHERE collection = ${collectionParam} ${filters.sql.map((f) => `AND ${f}`).join(" ")}
413
413
  ORDER BY ${orderExpr} ${order === "desc" ? "DESC" : "ASC"}
414
414
  LIMIT ${limit} OFFSET ${offset}`,
@@ -421,9 +421,9 @@ const postgresDialect = {
421
421
  const next = counter();
422
422
  const patternParam = next();
423
423
  const collectionParam = next();
424
- const filters = fieldFilters(where, next);
424
+ const filters = fieldFilters$1(where, next);
425
425
  return {
426
- sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs
426
+ sql: `SELECT ${DOC_COLUMNS$1} FROM _canopy_docs
427
427
  WHERE data ILIKE ${patternParam} AND collection = ${collectionParam}
428
428
  ${filters.sql.map((f) => `AND ${f}`).join(" ")}
429
429
  ORDER BY created_at
@@ -439,7 +439,7 @@ const postgresDialect = {
439
439
  return {
440
440
  sql: `UPDATE _canopy_docs SET data = (data::jsonb || $1::jsonb)::text, updated_at = $2
441
441
  WHERE collection = $3 AND id = $4
442
- RETURNING ${DOC_COLUMNS}`,
442
+ RETURNING ${DOC_COLUMNS$1}`,
443
443
  params: [
444
444
  patchJson,
445
445
  now,
@@ -462,11 +462,172 @@ const postgresDialect = {
462
462
  }
463
463
  };
464
464
  //#endregion
465
+ //#region ../data-plane/src/dialect-sqlite.ts
466
+ /**
467
+ * SQLite/D1 doc-store dialect: one shared table for all collections plus an
468
+ * external-content FTS5 index kept in sync by triggers. Statements are one
469
+ * call each — BEGIN…END bodies rule out naive multi-statement splitting on
470
+ * some hosts.
471
+ */
472
+ const DOC_COLUMNS = "id, data, created_at, updated_at";
473
+ const DOC_STORE_DDL = [
474
+ `CREATE TABLE IF NOT EXISTS _canopy_docs (
475
+ collection TEXT NOT NULL,
476
+ id TEXT NOT NULL,
477
+ data TEXT NOT NULL,
478
+ created_at TEXT NOT NULL,
479
+ updated_at TEXT NOT NULL,
480
+ PRIMARY KEY (collection, id)
481
+ )`,
482
+ `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`,
483
+ `CREATE VIRTUAL TABLE IF NOT EXISTS _canopy_docs_fts
484
+ USING fts5(data, content='_canopy_docs', content_rowid='rowid')`,
485
+ `CREATE TRIGGER IF NOT EXISTS _canopy_docs_fts_insert AFTER INSERT ON _canopy_docs BEGIN
486
+ INSERT INTO _canopy_docs_fts (rowid, data) VALUES (new.rowid, new.data);
487
+ END`,
488
+ `CREATE TRIGGER IF NOT EXISTS _canopy_docs_fts_delete AFTER DELETE ON _canopy_docs BEGIN
489
+ INSERT INTO _canopy_docs_fts (_canopy_docs_fts, rowid, data) VALUES ('delete', old.rowid, old.data);
490
+ END`,
491
+ `CREATE TRIGGER IF NOT EXISTS _canopy_docs_fts_update AFTER UPDATE ON _canopy_docs BEGIN
492
+ INSERT INTO _canopy_docs_fts (_canopy_docs_fts, rowid, data) VALUES ('delete', old.rowid, old.data);
493
+ INSERT INTO _canopy_docs_fts (rowid, data) VALUES (new.rowid, new.data);
494
+ END`
495
+ ];
496
+ /**
497
+ * WHERE fragments for a field filter. All params are strings: the value rides
498
+ * as JSON text and `json_extract(?, '$')` rehydrates its SQLite type, so 5
499
+ * matches 5 and false matches false. `IS` makes null match JSON null (and
500
+ * absent fields).
501
+ */
502
+ function fieldFilters(where, table = "") {
503
+ const sql = [];
504
+ const params = [];
505
+ for (const [field, value] of Object.entries(where)) {
506
+ const column = META_COLUMNS[field];
507
+ if (column) {
508
+ sql.push(`${table}${column} IS json_extract(?, '$')`);
509
+ params.push(JSON.stringify(value));
510
+ } else {
511
+ sql.push(`json_extract(${table}data, ?) IS json_extract(?, '$')`);
512
+ params.push(`$.${field}`, JSON.stringify(value));
513
+ }
514
+ }
515
+ return {
516
+ sql,
517
+ params
518
+ };
519
+ }
520
+ /** Query text → FTS5 MATCH expression: quoted tokens, implicit AND. */
521
+ function ftsMatch(text) {
522
+ return text.split(/\s+/).filter(Boolean).map((token) => `"${token.replaceAll("\"", "\"\"")}"`).join(" ");
523
+ }
524
+ const sqliteDialect = {
525
+ name: "sqlite",
526
+ fts: true,
527
+ ddl: DOC_STORE_DDL,
528
+ insert(collection, id, data, now) {
529
+ return {
530
+ sql: "INSERT INTO _canopy_docs (collection, id, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
531
+ params: [
532
+ collection,
533
+ id,
534
+ data,
535
+ now,
536
+ now
537
+ ]
538
+ };
539
+ },
540
+ get(collection, id) {
541
+ return {
542
+ sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs WHERE collection = ? AND id = ?`,
543
+ params: [collection, id]
544
+ };
545
+ },
546
+ find({ collection, where, orderBy, order, limit, offset }) {
547
+ const filters = fieldFilters(where);
548
+ const params = [collection, ...filters.params];
549
+ let orderExpr = "created_at";
550
+ if (orderBy !== void 0) {
551
+ const column = META_COLUMNS[orderBy];
552
+ if (column) orderExpr = column;
553
+ else {
554
+ orderExpr = "json_extract(data, ?)";
555
+ params.push(`$.${orderBy}`);
556
+ }
557
+ }
558
+ return {
559
+ sql: `SELECT ${DOC_COLUMNS} FROM _canopy_docs
560
+ WHERE collection = ? ${filters.sql.map((f) => `AND ${f}`).join(" ")}
561
+ ORDER BY ${orderExpr} ${order === "desc" ? "DESC" : "ASC"}
562
+ LIMIT ${limit} OFFSET ${offset}`,
563
+ params
564
+ };
565
+ },
566
+ search({ collection, text, where, limit, offset }) {
567
+ const match = ftsMatch(text);
568
+ if (!match) return null;
569
+ const filters = fieldFilters(where, "d.");
570
+ return {
571
+ sql: `SELECT d.id, d.data, d.created_at, d.updated_at
572
+ FROM _canopy_docs_fts
573
+ JOIN _canopy_docs d ON d.rowid = _canopy_docs_fts.rowid
574
+ WHERE _canopy_docs_fts MATCH ? AND d.collection = ?
575
+ ${filters.sql.map((f) => `AND ${f}`).join(" ")}
576
+ ORDER BY _canopy_docs_fts.rank
577
+ LIMIT ${limit} OFFSET ${offset}`,
578
+ params: [
579
+ match,
580
+ collection,
581
+ ...filters.params
582
+ ]
583
+ };
584
+ },
585
+ patch(collection, id, patchJson, now) {
586
+ return {
587
+ sql: `UPDATE _canopy_docs SET data = json_patch(data, ?), updated_at = ?
588
+ WHERE collection = ? AND id = ?
589
+ RETURNING ${DOC_COLUMNS}`,
590
+ params: [
591
+ patchJson,
592
+ now,
593
+ collection,
594
+ id
595
+ ]
596
+ };
597
+ },
598
+ del(collection, id) {
599
+ return {
600
+ sql: "DELETE FROM _canopy_docs WHERE collection = ? AND id = ?",
601
+ params: [collection, id]
602
+ };
603
+ },
604
+ /**
605
+ * D1 export refuses databases containing virtual tables, so data-safety
606
+ * operations drop the FTS index (and its triggers) around an export and
607
+ * rebuild it afterwards. The index is external-content over _canopy_docs —
608
+ * dropping it loses nothing a rebuild can't restore.
609
+ */
610
+ dropFtsIndex() {
611
+ return [...[
612
+ "insert",
613
+ "delete",
614
+ "update"
615
+ ].map((trigger) => `DROP TRIGGER IF EXISTS _canopy_docs_fts_${trigger}`), "DROP TABLE IF EXISTS _canopy_docs_fts"];
616
+ },
617
+ rebuildFtsIndex() {
618
+ return [...DOC_STORE_DDL, "INSERT INTO _canopy_docs_fts (_canopy_docs_fts) VALUES ('rebuild')"];
619
+ }
620
+ };
621
+ //#endregion
465
622
  //#region ../data-plane/src/doc-store.ts
466
623
  /** Creates the doc store if missing. Idempotent; hosts decide when to memoize. */
467
624
  async function ensureDocStore(store, dialect) {
468
625
  for (const ddl of dialect.ddl) await store.query(ddl);
469
626
  }
627
+ /** Recreates the doc store's FTS index and re-indexes every document. */
628
+ async function rebuildFtsIndex(store, dialect) {
629
+ for (const sql of dialect.rebuildFtsIndex()) await store.query(sql);
630
+ }
470
631
  //#endregion
471
632
  //#region ../data-plane/src/sql-dump.ts
472
633
  /**
@@ -538,6 +699,123 @@ function splitSqlStatements(dump) {
538
699
  return statements;
539
700
  }
540
701
  //#endregion
702
+ //#region ../data-plane/src/migrations.ts
703
+ var MigrationError = class extends Error {
704
+ code;
705
+ /** The migration file the error is about, when there is one. */
706
+ migration;
707
+ constructor(code, message, migration) {
708
+ super(message);
709
+ this.name = "MigrationError";
710
+ this.code = code;
711
+ if (migration !== void 0) this.migration = migration;
712
+ }
713
+ };
714
+ /** Same DDL in both dialects — TEXT keys, ISO-8601 timestamps. */
715
+ const JOURNAL_DDL = `CREATE TABLE IF NOT EXISTS _canopy_migrations (
716
+ name TEXT PRIMARY KEY,
717
+ hash TEXT NOT NULL,
718
+ applied_at TEXT NOT NULL
719
+ )`;
720
+ /** Filenames only — no paths, no shell metacharacters, journal-key sized. */
721
+ const MIGRATION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
722
+ /** Transaction control and session settings the host channel can't honor. */
723
+ const SKIPPED_STATEMENT_RE = /^(BEGIN|COMMIT|ROLLBACK|SET\s+search_path|PRAGMA)\b/i;
724
+ async function sha256Hex(text) {
725
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
726
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
727
+ }
728
+ /** Hash over normalized line endings so a CRLF checkout isn't an "edit". */
729
+ function migrationHash(sql) {
730
+ return sha256Hex(sql.replaceAll("\r\n", "\n"));
731
+ }
732
+ async function ensureJournal(store) {
733
+ await store.query(JOURNAL_DDL);
734
+ }
735
+ function sortedByName(files) {
736
+ return [...files].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
737
+ }
738
+ /**
739
+ * Reject any pending statement that mentions the platform's reserved prefix.
740
+ * Deliberately conservative (a string literal naming `_canopy_docs` trips it
741
+ * too — docs/13 §8.2): the doc store and the journal are not the user's to
742
+ * migrate, and a false positive has an easy workaround (don't say `_canopy_`).
743
+ */
744
+ function guardReservedTables(file, statements) {
745
+ for (const statement of statements) if (/_canopy_/i.test(statement)) throw new MigrationError("reserved_table", `Migration ${file.name} references a reserved _canopy_* object — platform tables are not migratable.`, file.name);
746
+ }
747
+ function validateNames(files) {
748
+ const seen = /* @__PURE__ */ new Set();
749
+ for (const file of files) {
750
+ if (!MIGRATION_NAME_RE.test(file.name)) throw new MigrationError("invalid_migration", `Invalid migration name ${JSON.stringify(file.name)} — use plain filenames (letters, digits, ".", "_", "-"; max 128 chars).`, file.name);
751
+ if (seen.has(file.name)) throw new MigrationError("invalid_migration", `Duplicate migration name ${file.name}.`);
752
+ seen.add(file.name);
753
+ }
754
+ }
755
+ /**
756
+ * Compare the provided files against the journal: which are pending, which
757
+ * already applied (hash-verified), which journaled-but-missing. Throws
758
+ * `edited_migration` when an applied file's content changed — write a new
759
+ * migration instead, like every mainstream tool.
760
+ */
761
+ async function planMigrations(store, files) {
762
+ validateNames(files);
763
+ await ensureJournal(store);
764
+ const { results } = await store.query("SELECT name, hash FROM _canopy_migrations");
765
+ const journal = new Map(results.map((row) => [String(row["name"]), String(row["hash"])]));
766
+ const ordered = sortedByName(files);
767
+ const pending = [];
768
+ const applied = [];
769
+ for (const file of ordered) {
770
+ const journaledHash = journal.get(file.name);
771
+ if (journaledHash === void 0) {
772
+ pending.push(file);
773
+ continue;
774
+ }
775
+ if (journaledHash !== await migrationHash(file.sql)) throw new MigrationError("edited_migration", `Migration ${file.name} was edited after it was applied — write a new migration instead of changing history.`, file.name);
776
+ applied.push(file.name);
777
+ }
778
+ for (const file of pending) guardReservedTables(file, splitSqlStatements(file.sql));
779
+ const provided = new Set(ordered.map((f) => f.name));
780
+ return {
781
+ pending,
782
+ applied,
783
+ missing: [...journal.keys()].filter((name) => !provided.has(name)).sort()
784
+ };
785
+ }
786
+ /**
787
+ * Apply every pending migration in order, journaling each file after its
788
+ * statements ran. All validation (names, hashes, reserved tables) happens
789
+ * before the first statement executes; a statement failure surfaces as
790
+ * `statement_failed` naming the file and statement.
791
+ */
792
+ async function applyMigrations(store, dialect, files) {
793
+ const plan = await planMigrations(store, files);
794
+ const statementsByFile = plan.pending.map((file) => ({
795
+ file,
796
+ statements: splitSqlStatements(file.sql).filter((s) => !SKIPPED_STATEMENT_RE.test(s))
797
+ }));
798
+ const journalInsert = dialect.name === "postgres" ? "INSERT INTO _canopy_migrations (name, hash, applied_at) VALUES ($1, $2, $3)" : "INSERT INTO _canopy_migrations (name, hash, applied_at) VALUES (?, ?, ?)";
799
+ const applied = [];
800
+ for (const { file, statements } of statementsByFile) {
801
+ for (const statement of statements) try {
802
+ await store.query(statement);
803
+ } catch (err) {
804
+ throw new MigrationError("statement_failed", `Migration ${file.name} failed: ${err instanceof Error ? err.message : String(err)}\n in: ${statement.slice(0, 160)}${statement.length > 160 ? "…" : ""}`, file.name);
805
+ }
806
+ await store.query(journalInsert, [
807
+ file.name,
808
+ await migrationHash(file.sql),
809
+ (/* @__PURE__ */ new Date()).toISOString()
810
+ ]);
811
+ applied.push(file.name);
812
+ }
813
+ return {
814
+ applied,
815
+ missing: plan.missing
816
+ };
817
+ }
818
+ //#endregion
541
819
  //#region src/fs-blob.ts
542
820
  /**
543
821
  * Filesystem BlobBucket for `canopy dev --local` — same structural interface
@@ -705,10 +983,12 @@ function createFsBlobBucket(root) {
705
983
  //#region src/local.ts
706
984
  /**
707
985
  * canopy dev --local — the offline sandbox (docs/02-decisions.md D12,
708
- * docs/05-gcp-plan.md §10.2 / Phase G4): PGlite + filesystem blobs under the
709
- * project's .canopy/local/, answering the SDK's data plane with THE SAME
710
- * routers canopy-api mounts (@mediamonks/canopy-data-plane) — the sandbox can't
711
- * drift from the platform because they share the implementation.
986
+ * docs/05-gcp-plan.md §10.2 / Phase G4): a local database + filesystem blobs
987
+ * under the project's .canopy/local/, answering the SDK's data plane with THE
988
+ * SAME routers canopy-api mounts (@mediamonks/canopy-data-plane) — the sandbox
989
+ * can't drift from the platform because they share the implementation. The
990
+ * canopy.json "dialect" lock picks the engine (sqlite → node:sqlite, postgres →
991
+ * PGlite) so raw sql() written for the project's target runs unchanged.
712
992
  *
713
993
  * Sync is pull-only: `--pull` hydrates the local database from a fresh prod
714
994
  * export; nothing ever pushes back. ai/services aren't data — dev.ts proxies
@@ -722,27 +1002,77 @@ function resolveSdkBundle() {
722
1002
  return bundle;
723
1003
  }
724
1004
  /** Downloads a fresh SQL dump of the project's production database (pull-only sync). */
725
- async function fetchProjectDump(slug) {
726
- const { url } = await apiDataRequest("POST", "/db/export", slug);
1005
+ async function fetchProjectDump(slug, channel = "live") {
1006
+ const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channel);
727
1007
  const download = await fetch(url);
728
1008
  if (!download.ok) throw new Error(`Dump download failed (HTTP ${download.status}).`);
729
1009
  return download.text();
730
1010
  }
731
- async function createSandbox(options) {
1011
+ async function createPgliteDb(persistDir) {
732
1012
  const { PGlite } = await import("@electric-sql/pglite");
733
- const dataDir = options.persistDir ? join(options.persistDir, "pg") : void 0;
1013
+ const dataDir = persistDir ? join(persistDir, "pg") : void 0;
734
1014
  if (dataDir) mkdirSync(dataDir, { recursive: true });
735
1015
  const db = dataDir ? new PGlite(dataDir) : new PGlite();
736
1016
  await db.waitReady;
1017
+ return {
1018
+ store: { query: async (sql, params = []) => {
1019
+ const result = await db.query(sql, params);
1020
+ return {
1021
+ results: result.rows,
1022
+ meta: { changes: Number(result.affectedRows ?? 0) }
1023
+ };
1024
+ } },
1025
+ exec: async (sql) => {
1026
+ await db.exec(sql);
1027
+ },
1028
+ reset: async () => {
1029
+ const tables = (await db.query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`)).rows;
1030
+ for (const { tablename } of tables) await db.exec(`DROP TABLE IF EXISTS "${tablename.replaceAll("\"", "\"\"")}" CASCADE`);
1031
+ },
1032
+ close: () => db.close()
1033
+ };
1034
+ }
1035
+ async function createSqliteDb(persistDir) {
1036
+ let DatabaseSync;
1037
+ try {
1038
+ ({DatabaseSync} = await import("node:sqlite"));
1039
+ const probe = new DatabaseSync(":memory:");
1040
+ probe.exec("CREATE VIRTUAL TABLE _fts_probe USING fts5(x)");
1041
+ probe.close();
1042
+ } catch {
1043
+ throw new Error(`The sqlite sandbox needs Node 24+ (node:sqlite with FTS5); this is Node ${process.version}. Upgrade Node, or remove the "dialect" lock from canopy.json to use the PGlite sandbox.`);
1044
+ }
1045
+ if (persistDir) mkdirSync(persistDir, { recursive: true });
1046
+ const db = new DatabaseSync(persistDir ? join(persistDir, "sqlite.db") : ":memory:");
1047
+ const listTables = (where) => db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND ${where}`).all().map((row) => row.name);
1048
+ return {
1049
+ store: { query: async (sql, params = []) => {
1050
+ const bound = params.map((p) => typeof p === "boolean" ? p ? 1 : 0 : p);
1051
+ const results = db.prepare(sql).all(...bound);
1052
+ const { c } = db.prepare("SELECT changes() AS c").get();
1053
+ return {
1054
+ results,
1055
+ meta: { changes: Number(c) }
1056
+ };
1057
+ } },
1058
+ exec: async (sql) => {
1059
+ db.exec(sql);
1060
+ },
1061
+ reset: async () => {
1062
+ for (const name of listTables(`sql LIKE 'CREATE VIRTUAL TABLE%'`)) db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
1063
+ for (const name of listTables(`name NOT LIKE 'sqlite_%'`)) db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
1064
+ },
1065
+ close: async () => {
1066
+ db.close();
1067
+ }
1068
+ };
1069
+ }
1070
+ async function createSandbox(options) {
1071
+ const dialect = options.dialect === "sqlite" ? sqliteDialect : postgresDialect;
1072
+ const db = options.dialect === "sqlite" ? await createSqliteDb(options.persistDir) : await createPgliteDb(options.persistDir);
1073
+ const store = db.store;
737
1074
  const bucket = options.persistDir ? createFsBlobBucket(join(options.persistDir, "blob")) : createMemoryBlobBucket();
738
- const store = { query: async (sql, params = []) => {
739
- const result = await db.query(sql, params);
740
- return {
741
- results: result.rows,
742
- meta: { changes: Number(result.affectedRows ?? 0) }
743
- };
744
- } };
745
- await ensureDocStore(store, postgresDialect);
1075
+ await ensureDocStore(store, dialect);
746
1076
  const app = new Hono();
747
1077
  const ownProject = (c) => {
748
1078
  const requested = c.req.header("x-canopy-project") ?? c.req.query("project");
@@ -750,7 +1080,7 @@ async function createSandbox(options) {
750
1080
  };
751
1081
  app.route("/data/v1", dbRouter({ store: async (c) => ownProject(c) ?? {
752
1082
  store,
753
- dialect: postgresDialect
1083
+ dialect
754
1084
  } }));
755
1085
  app.route("/data/v1", blobRouter({
756
1086
  store: async (c) => ownProject(c) ?? {
@@ -768,6 +1098,10 @@ async function createSandbox(options) {
768
1098
  "cache-control": "no-store"
769
1099
  } });
770
1100
  });
1101
+ app.get("/data/v1/platform", (c) => c.json({
1102
+ dialect: dialect.name,
1103
+ capabilities: { fts: dialect.fts }
1104
+ }));
771
1105
  app.get("/data/v1/auth/me", (c) => c.json({
772
1106
  id: "local-dev",
773
1107
  email: options.email ?? "dev@localhost",
@@ -793,13 +1127,8 @@ async function createSandbox(options) {
793
1127
  else if (!pathname.startsWith("/data/v1/") && !pathname.startsWith("/control/v1/")) return Response.json({ error: "not_found" }, { status: 404 });
794
1128
  return app.fetch(new Request(`http://sandbox${pathname}${url.search}`, request));
795
1129
  }
796
- /** Drop every user table so a dump lands on a clean slate. */
797
- async function resetLocal() {
798
- const tables = (await db.query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`)).rows;
799
- for (const { tablename } of tables) await db.exec(`DROP TABLE IF EXISTS "${tablename.replaceAll("\"", "\"\"")}" CASCADE`);
800
- }
801
1130
  async function applyDump(sql) {
802
- await resetLocal();
1131
+ await db.reset();
803
1132
  for (const statement of splitSqlStatements(sql)) {
804
1133
  if (/^(BEGIN|COMMIT|ROLLBACK|SET\s+search_path|PRAGMA)\b/i.test(statement)) continue;
805
1134
  try {
@@ -808,18 +1137,13 @@ async function createSandbox(options) {
808
1137
  throw new Error(`Dump statement failed: ${err instanceof Error ? err.message : String(err)}\n in: ${statement.slice(0, 120)}${statement.length > 120 ? "…" : ""}`);
809
1138
  }
810
1139
  }
811
- await ensureDocStore(store, postgresDialect);
1140
+ await rebuildFtsIndex(store, dialect);
812
1141
  }
813
1142
  return {
814
1143
  handle,
815
1144
  applyDump,
816
- dispose: async () => {
817
- await db.close();
818
- if (options.persistDir === false && dataDir && existsSync(dataDir)) rmSync(dataDir, {
819
- recursive: true,
820
- force: true
821
- });
822
- }
1145
+ applyMigrations: (files) => applyMigrations(store, dialect, files),
1146
+ dispose: () => db.close()
823
1147
  };
824
1148
  }
825
1149
  //#endregion
@@ -1,5 +1,5 @@
1
- import { d as readConfigFile, f as setActiveProfile, g as isProfileId, h as getBakedProfile, l as clearAllProfileTokens, m as writeConfigFile, n as siteConfig, p as upsertProfileAuth, u as listBakedProfiles } from "./site-BJjoePQS.mjs";
2
- import { n as reportErrors, t as openBrowser } from "./ui-DThlyHgo.mjs";
1
+ import { _ as getBakedProfile, d as clearAllProfileTokens, f as listBakedProfiles, g as writeConfigFile, h as upsertProfileAuth, m as setActiveProfile, n as siteConfig, p as readConfigFile, v as isProfileId } from "./site-BUlQR117.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { createHash, randomBytes } from "node:crypto";
@@ -287,7 +287,9 @@ async function login(json, profileFlag) {
287
287
  url: authorizeHref
288
288
  }));
289
289
  else {
290
- p.note(`If nothing opens, visit:\n${authorizeHref}`, "Opening OneLogin in your browser");
290
+ p.log.step("Opening OneLogin in your browser");
291
+ p.log.message("If nothing opens, visit:");
292
+ console.log(authorizeHref);
291
293
  spin?.start("Waiting for you to sign in in the browser…");
292
294
  }
293
295
  let code;
@@ -0,0 +1,50 @@
1
+ import { u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
+ import { o as apiDataRequest } from "./ui-D8HxX0Ng.mjs";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ //#region src/migrations.ts
6
+ /**
7
+ * The CLI side of schema migrations (docs/13-migrations-and-drizzle.md):
8
+ * reads the project's migration files (canopy.json "migrations" dir, e.g.
9
+ * Drizzle's `drizzle-kit generate` output) and sends them to the platform's
10
+ * migrate primitive. Shared by `canopy db migrate`, the deploy step, and the
11
+ * `--local` sandbox boot.
12
+ */
13
+ /**
14
+ * The project's migration files in apply order, or undefined when canopy.json
15
+ * configures no "migrations" dir. A configured-but-unreadable dir is an error;
16
+ * a readable dir with no .sql files is an empty list.
17
+ */
18
+ function readMigrationFiles(dir) {
19
+ const migrationsPath = tryReadCanopyJson(dir)?.migrations;
20
+ if (!migrationsPath) return void 0;
21
+ const migrationsDir = resolve(dir, migrationsPath);
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(migrationsDir, { withFileTypes: true });
25
+ } catch {
26
+ throw new Error(`Migrations directory not found: ${migrationsDir} (canopy.json "migrations").`);
27
+ }
28
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".sql")).map((entry) => ({
29
+ name: entry.name,
30
+ sql: readFileSync(join(migrationsDir, entry.name), "utf8")
31
+ })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
32
+ }
33
+ /** Run (or dry-run) the project's migrations against a channel's database. */
34
+ function migrateProject(slug, migrations, plan = false, channel = "live") {
35
+ return apiDataRequest("POST", "/db/migrate", slug, {
36
+ migrations,
37
+ plan
38
+ }, void 0, channel);
39
+ }
40
+ /** One-line-per-fact summary shared by `db migrate` and the deploy step. */
41
+ function describeMigrateResult(result) {
42
+ const lines = [];
43
+ for (const name of result.appliedNow) lines.push(`applied ${name}`);
44
+ if (result.appliedNow.length === 0 && result.pending.length === 0) lines.push(`nothing pending (${result.applied.length} applied previously)`);
45
+ if (result.snapshot !== void 0) lines.push(`pre-migrate snapshot: ${result.snapshot} (canopy db restore --snapshot …)`);
46
+ if (result.missing.length > 0) lines.push(`note: ${result.missing.length} journaled migration(s) have no local file`);
47
+ return lines;
48
+ }
49
+ //#endregion
50
+ export { migrateProject as n, readMigrationFiles as r, describeMigrateResult as t };
@@ -1,5 +1,5 @@
1
- import { i as projectDirArgs, o as projectUrl, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { n as reportErrors, t as openBrowser } from "./ui-DThlyHgo.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-BUlQR117.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/open.ts
5
5
  /**
@@ -11,10 +11,17 @@ const openCommand = defineCommand({
11
11
  name: "open",
12
12
  description: "Open the deployed project in your browser"
13
13
  },
14
- args: projectDirArgs,
14
+ args: {
15
+ ...projectDirArgs,
16
+ staging: {
17
+ type: "boolean",
18
+ default: false,
19
+ description: "Open the staging channel instead of live"
20
+ }
21
+ },
15
22
  run: reportErrors("open", async ({ args }) => {
16
23
  const { slug } = resolveProject(args);
17
- const url = projectUrl(slug);
24
+ const url = args.staging ? stagingUrl(slug) : projectUrl(slug);
18
25
  console.log(`Opening ${url}`);
19
26
  openBrowser(url);
20
27
  })
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.5.1";
2
+ var version = "0.7.0";
3
3
  //#endregion
4
4
  export { version as t };