@mediamonks/canopy-cli 0.5.1 → 0.6.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/{db-BW1ZolWn.mjs → db-BpnjQUkF.mjs} +42 -3
- package/dist/{deploy-alC2--pZ.mjs → deploy-DdSSL227.mjs} +29 -2
- package/dist/dev-DV5ud7AJ.mjs +2 -0
- package/dist/{dev-CQQhMOoD.mjs → dev-OxeYndd1.mjs} +24 -9
- package/dist/docs/ci.md +1 -3
- package/dist/docs/cli.md +3 -4
- package/dist/docs/data.md +5 -4
- package/dist/docs/deploy.md +1 -3
- package/dist/docs/dev.md +13 -11
- package/dist/docs/getting-started.md +1 -3
- package/dist/docs/infrastructure.md +27 -15
- package/dist/docs/migrations.md +166 -0
- package/dist/docs/porting-nextjs.md +232 -0
- package/dist/docs/sdk-ai.md +103 -0
- package/dist/docs/sdk-auth.md +57 -0
- package/dist/docs/sdk-database.md +126 -0
- package/dist/docs/sdk-files.md +82 -0
- package/dist/docs/sdk-services.md +58 -0
- package/dist/docs/sdk.md +113 -0
- package/dist/docs/static-serving.md +4 -1
- package/dist/docs/troubleshooting.md +1 -3
- package/dist/{docs-DvSPpSIR.mjs → docs-Cept8t_3.mjs} +23 -10
- package/dist/index.mjs +20 -20
- package/dist/{local-C5-GWJcx.mjs → local-D9OVEHDg.mjs} +363 -39
- package/dist/{login-BO6abf40.mjs → login-Cb8D8a1Z.mjs} +5 -3
- package/dist/migrations-B0kJarjr.mjs +50 -0
- package/dist/{open-DnlYDqrU.mjs → open-BmgYBSdR.mjs} +2 -2
- package/dist/{package-CHoZKzaw.mjs → package-D1wDsLSU.mjs} +1 -1
- package/dist/{projects-ijfe7Ffx.mjs → projects-CoGh8m9A.mjs} +17 -9
- package/dist/{site-BJjoePQS.mjs → site-DQnysZ3W.mjs} +8 -1
- package/dist/skill/canopy/SKILL.md +18 -1
- package/dist/{skill-CW0QSbAJ.mjs → skill-192EdixC.mjs} +2 -2
- package/dist/templates/worker/AGENTS.md +3 -0
- package/dist/{token-BeF9zczg.mjs → token-CWgp_Lqe.mjs} +2 -2
- package/dist/{ui-DThlyHgo.mjs → ui-BhzMeKQl.mjs} +1 -1
- package/dist/{upgrade-CGz1yzsW.mjs → upgrade-BrHSSg2e.mjs} +2 -2
- package/dist/{usage-DQpH7A3X.mjs → usage-CYQRLXwf.mjs} +2 -2
- package/package.json +3 -3
- package/dist/dev-BJ5xEwqW.mjs +0 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { o as apiDataRequest } from "./ui-
|
|
2
|
-
import { n as rewriteApiPath } from "./dev-
|
|
1
|
+
import { o as apiDataRequest } from "./ui-BhzMeKQl.mjs";
|
|
2
|
+
import { n as rewriteApiPath } from "./dev-OxeYndd1.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):
|
|
709
|
-
* project's .canopy/local/, answering the SDK's data plane with THE
|
|
710
|
-
* routers canopy-api mounts (@mediamonks/canopy-data-plane) — the sandbox
|
|
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
|
|
@@ -728,21 +1008,71 @@ async function fetchProjectDump(slug) {
|
|
|
728
1008
|
if (!download.ok) throw new Error(`Dump download failed (HTTP ${download.status}).`);
|
|
729
1009
|
return download.text();
|
|
730
1010
|
}
|
|
731
|
-
async function
|
|
1011
|
+
async function createPgliteDb(persistDir) {
|
|
732
1012
|
const { PGlite } = await import("@electric-sql/pglite");
|
|
733
|
-
const dataDir =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1140
|
+
await rebuildFtsIndex(store, dialect);
|
|
812
1141
|
}
|
|
813
1142
|
return {
|
|
814
1143
|
handle,
|
|
815
1144
|
applyDump,
|
|
816
|
-
|
|
817
|
-
|
|
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 {
|
|
2
|
-
import { n as reportErrors, t as openBrowser } from "./ui-
|
|
1
|
+
import { _ as isProfileId, d as listBakedProfiles, f as readConfigFile, g as getBakedProfile, h as writeConfigFile, m as upsertProfileAuth, n as siteConfig, p as setActiveProfile, u as clearAllProfileTokens } from "./site-DQnysZ3W.mjs";
|
|
2
|
+
import { n as reportErrors, t as openBrowser } from "./ui-BhzMeKQl.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.
|
|
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 { l as tryReadCanopyJson } from "./site-DQnysZ3W.mjs";
|
|
2
|
+
import { o as apiDataRequest } from "./ui-BhzMeKQl.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 its live database. */
|
|
34
|
+
function migrateProject(slug, migrations, plan = false) {
|
|
35
|
+
return apiDataRequest("POST", "/db/migrate", slug, {
|
|
36
|
+
migrations,
|
|
37
|
+
plan
|
|
38
|
+
});
|
|
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 {
|
|
2
|
-
import { n as reportErrors, t as openBrowser } from "./ui-
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, s as projectUrl } from "./site-DQnysZ3W.mjs";
|
|
2
|
+
import { n as reportErrors, t as openBrowser } from "./ui-BhzMeKQl.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/open.ts
|
|
5
5
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-
|
|
1
|
+
import { l as tryReadCanopyJson, n as siteConfig, s as projectUrl } from "./site-DQnysZ3W.mjs";
|
|
2
|
+
import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-BhzMeKQl.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, relative, resolve, sep } from "node:path";
|
|
@@ -75,7 +75,12 @@ function writeCanopyJson(dir, fields) {
|
|
|
75
75
|
writeFileSync(join(dir, "canopy.json"), `${JSON.stringify(next, null, 2)}\n`);
|
|
76
76
|
}
|
|
77
77
|
function slugify(input) {
|
|
78
|
-
|
|
78
|
+
let slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-+|-+$)/g, "").slice(0, 40);
|
|
79
|
+
while (slug.includes("-dot-")) slug = slug.replaceAll("-dot-", "-");
|
|
80
|
+
return slug;
|
|
81
|
+
}
|
|
82
|
+
function shouldScaffoldCreate(requested, cwd = process.cwd()) {
|
|
83
|
+
return requested ?? !existsSync(join(cwd, "canopy.json"));
|
|
79
84
|
}
|
|
80
85
|
const createCommand = defineCommand({
|
|
81
86
|
meta: {
|
|
@@ -98,7 +103,6 @@ const createCommand = defineCommand({
|
|
|
98
103
|
},
|
|
99
104
|
scaffold: {
|
|
100
105
|
type: "boolean",
|
|
101
|
-
default: true,
|
|
102
106
|
description: "Scaffold ./<slug>/ from the starter template",
|
|
103
107
|
negativeDescription: "Only register — no files written"
|
|
104
108
|
}
|
|
@@ -118,12 +122,16 @@ const createCommand = defineCommand({
|
|
|
118
122
|
message: "Slug",
|
|
119
123
|
defaultValue: slugify(name),
|
|
120
124
|
placeholder: slugify(name),
|
|
121
|
-
validate: (value) =>
|
|
125
|
+
validate: (value) => {
|
|
126
|
+
if (value && !/^[a-z0-9][a-z0-9-]*$/.test(value)) return "Lowercase letters, digits and dashes only.";
|
|
127
|
+
if (value?.includes("-dot-")) return `"-dot-" is reserved for preview hostnames.`;
|
|
128
|
+
}
|
|
122
129
|
})).trim();
|
|
123
130
|
slug ||= slugify(name);
|
|
131
|
+
const scaffold = shouldScaffoldCreate(args.scaffold);
|
|
124
132
|
let template = TEMPLATES.find((t) => t.id === (args.template ?? "static"));
|
|
125
133
|
if (!template) throw new Error(`Unknown template "${args.template}" — available: ${TEMPLATES.map((t) => t.id).join(", ")}.`);
|
|
126
|
-
if (
|
|
134
|
+
if (scaffold && !args.template && process.stdin.isTTY) {
|
|
127
135
|
const id = unwrap(await p.select({
|
|
128
136
|
message: "Template",
|
|
129
137
|
options: TEMPLATES.filter((t) => !t.hidden).map((t) => ({
|
|
@@ -136,7 +144,7 @@ const createCommand = defineCommand({
|
|
|
136
144
|
}
|
|
137
145
|
if (template.hidden) p.log.warn("Per-project Workers are not deployable yet (Phase 2) — `canopy deploy` publishes the frontend only for now.");
|
|
138
146
|
const targetDir = resolve(slug);
|
|
139
|
-
if (
|
|
147
|
+
if (scaffold && existsSync(targetDir)) throw new Error(`Directory ./${slug} already exists — remove it, or use --no-scaffold to only register.`);
|
|
140
148
|
const spin = p.spinner();
|
|
141
149
|
spin.start(`Registering ${slug}…`);
|
|
142
150
|
let project;
|
|
@@ -150,7 +158,7 @@ const createCommand = defineCommand({
|
|
|
150
158
|
throw err;
|
|
151
159
|
}
|
|
152
160
|
spin.stop(`Created ${project.name} — ${projectUrl(project.slug)}`);
|
|
153
|
-
if (
|
|
161
|
+
if (scaffold) {
|
|
154
162
|
scaffoldTemplate(template.id, targetDir, {
|
|
155
163
|
name,
|
|
156
164
|
slug
|
|
@@ -166,7 +174,7 @@ const createCommand = defineCommand({
|
|
|
166
174
|
];
|
|
167
175
|
p.note(steps.join("\n"), `Scaffolded ./${slug}/ (${template.id}) — next`);
|
|
168
176
|
p.outro(`Live URL (after first deploy): ${projectUrl(project.slug)}`);
|
|
169
|
-
} else p.outro("Registered without scaffolding — add a canopy.json to deploy an existing app.");
|
|
177
|
+
} else p.outro(existsSync(join(process.cwd(), "canopy.json")) ? "Registered without scaffolding — the existing canopy.json was left unchanged." : "Registered without scaffolding — add a canopy.json to deploy an existing app.");
|
|
170
178
|
})
|
|
171
179
|
});
|
|
172
180
|
/** Empty enough to scaffold into: missing, or holding only dotfiles (.git, .agents…). */
|