@mediamonks/canopy-cli 0.7.22 → 0.7.24
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-jmYaum8F.mjs +251 -0
- package/dist/{deploy-DCggsLD7.mjs → deploy-CYKiBnrg.mjs} +3 -3
- package/dist/dev-C5BmSJo1.mjs +2 -0
- package/dist/{dev-CNqx_53I.mjs → dev-DVUEJ80c.mjs} +6 -6
- package/dist/{dev-worker-Bij9OUMk.mjs → dev-worker-CWU6XFe_.mjs} +35 -1
- package/dist/docs/ci.md +8 -1
- package/dist/docs/cli-egress.md +2 -1
- package/dist/docs/cli-login.md +2 -0
- package/dist/docs/cli-logout.md +9 -6
- package/dist/docs/cli-token.md +4 -2
- package/dist/docs/index.json +7 -2
- package/dist/docs/platform-ai-usage.md +36 -10
- package/dist/docs/platform-client-access.md +14 -2
- package/dist/docs/platform-egress.md +5 -3
- package/dist/docs/platform-members-and-access.md +29 -6
- package/dist/docs/platform-project-data.md +1 -0
- package/dist/docs/platform-security-headers.md +53 -0
- package/dist/docs/platform-slack.md +3 -1
- package/dist/docs/sdk-ai.md +102 -11
- package/dist/docs/sdk-database.md +30 -0
- package/dist/docs/sdk-files.md +6 -0
- package/dist/{docs-c0CdeBxh.mjs → docs-BLWio_65.mjs} +3 -3
- package/dist/{egress-BhbQfjjV.mjs → egress-BjN-7vil.mjs} +6 -3
- package/dist/{feedback-QhD8k6Ph.mjs → feedback-Bnmi1Ukv.mjs} +3 -3
- package/dist/index.mjs +26 -26
- package/dist/{local-CsSP4Vpt.mjs → local-CNPBZ89U.mjs} +38 -9
- package/dist/{login-BrEDKrb3.mjs → login-COZ7Xcm0.mjs} +57 -6
- package/dist/{logs-Beqz--Df.mjs → logs-B_YoflCq.mjs} +2 -2
- package/dist/{migrations-Bg9FEGFh.mjs → migrations-Btzr9_q7.mjs} +2 -2
- package/dist/{open-D7f1wNn3.mjs → open-D2hH933K.mjs} +2 -2
- package/dist/{package-BuwV_mje.mjs → package-DTm0EzxR.mjs} +1 -1
- package/dist/{projects-1r5387ui.mjs → projects-CLs6MEF7.mjs} +2 -2
- package/dist/{schedule-DK_Cv6Xf.mjs → schedule-B8zA8WlG.mjs} +2 -2
- package/dist/{site-BFaWfSVu.mjs → site-DM2J10CB.mjs} +1 -1
- package/dist/{skill-BvoYCIKk.mjs → skill-Dt49jdUT.mjs} +2 -2
- package/dist/{src-BKKAEkbH.mjs → src-BK0Hiwsn.mjs} +95 -3
- package/dist/{staging-CdpV2YHB.mjs → staging-pZpdZuj_.mjs} +2 -2
- package/dist/{token-C8Bmzwfl.mjs → token-BKwGa10J.mjs} +8 -4
- package/dist/{ui--atbZ-TM.mjs → ui-CUNkis89.mjs} +1 -1
- package/dist/{upgrade-0lmdjvom.mjs → upgrade-Cf81feok.mjs} +2 -2
- package/dist/{usage-C1-cvS2-.mjs → usage-UmzU6sGp.mjs} +2 -2
- package/package.json +3 -3
- package/dist/db-TaSLpQZ5.mjs +0 -204
- package/dist/dev-D7v7WK9O.mjs +0 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/schedule.ts
|
|
5
5
|
/**
|
|
@@ -170,7 +170,7 @@ function clearAllProfileTokens() {
|
|
|
170
170
|
function projectUrl(slug) {
|
|
171
171
|
const config = siteConfig();
|
|
172
172
|
const apex = config.projectDomain?.trim();
|
|
173
|
-
if (apex) return `${config.projectProtocol ??
|
|
173
|
+
if (apex) return `${config.projectProtocol ?? "https"}://${slug}.${apex.replace(/^\./, "")}`;
|
|
174
174
|
const apiHost = new URL(config.apiUrl).hostname;
|
|
175
175
|
if (apiHost === "run.app" || apiHost.endsWith(".run.app") || apiHost.startsWith("api.")) throw new Error("Cannot derive the project URL. Set projectDomain (baked profile or CANOPY_PROJECT_DOMAIN).");
|
|
176
176
|
return `https://${slug}.${apiHost.split(".").slice(-2).join(".")}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
2
|
-
import { r as reportErrors } from "./ui
|
|
1
|
+
import { t as version } from "./package-DTm0EzxR.mjs";
|
|
2
|
+
import { r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
//#region ../data-plane/src/blob-headers.ts
|
|
3
|
+
/**
|
|
4
|
+
* Blob (SDK file store) serving policy. Blobs carry a caller-chosen content
|
|
5
|
+
* type and are served on the project's own origin, so a stored `text/html` or
|
|
6
|
+
* `image/svg+xml` upload would be a stored-XSS page with the project's origin.
|
|
7
|
+
* Types browsers can render harmlessly stay inline; SVG stays inline but is
|
|
8
|
+
* sandboxed (an `<img>` never runs its scripts, and a direct navigation gets an
|
|
9
|
+
* opaque origin); everything else — HTML, XML, scripts, unknown binaries —
|
|
10
|
+
* downloads instead of rendering.
|
|
11
|
+
*/
|
|
12
|
+
const INLINE_SAFE_TYPES = /* @__PURE__ */ new Set([
|
|
13
|
+
"image/png",
|
|
14
|
+
"image/jpeg",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"image/webp",
|
|
17
|
+
"image/avif",
|
|
18
|
+
"image/bmp",
|
|
19
|
+
"image/x-icon",
|
|
20
|
+
"image/vnd.microsoft.icon",
|
|
21
|
+
"text/plain",
|
|
22
|
+
"text/csv",
|
|
23
|
+
"application/json",
|
|
24
|
+
"application/pdf"
|
|
25
|
+
]);
|
|
26
|
+
const INLINE_SAFE_PREFIXES = [
|
|
27
|
+
"video/",
|
|
28
|
+
"audio/",
|
|
29
|
+
"font/"
|
|
30
|
+
];
|
|
31
|
+
const SANDBOX = "sandbox";
|
|
32
|
+
function blobServingHeaders(contentType, key) {
|
|
33
|
+
const type = contentType.split(";")[0].trim().toLowerCase();
|
|
34
|
+
const headers = {
|
|
35
|
+
"content-type": contentType,
|
|
36
|
+
"x-content-type-options": "nosniff"
|
|
37
|
+
};
|
|
38
|
+
if (type === "image/svg+xml") {
|
|
39
|
+
headers["content-security-policy"] = `default-src 'none'; style-src 'unsafe-inline'; ${SANDBOX}`;
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
if (type === "application/pdf") {
|
|
43
|
+
headers["content-security-policy"] = SANDBOX;
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
if (INLINE_SAFE_TYPES.has(type) || INLINE_SAFE_PREFIXES.some((p) => type.startsWith(p))) return headers;
|
|
47
|
+
headers["content-disposition"] = `attachment; filename="${(key.split("/").at(-1) ?? "download").replace(/["\r\n\\]/g, "_")}"`;
|
|
48
|
+
headers["content-security-policy"] = SANDBOX;
|
|
49
|
+
return headers;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
2
52
|
//#region ../data-plane/src/blob.ts
|
|
3
53
|
/**
|
|
4
54
|
* /blob — the SDK's file storage over an injectable BlobStore (a bucket
|
|
@@ -118,7 +168,7 @@ function blobRouter(config) {
|
|
|
118
168
|
const object = await store.bucket.get(store.prefix + key);
|
|
119
169
|
if (!object) return c.json({ error: "not_found" }, 404);
|
|
120
170
|
return new Response(object.body, { headers: {
|
|
121
|
-
|
|
171
|
+
...blobServingHeaders(object.httpMetadata?.contentType ?? "application/octet-stream", key),
|
|
122
172
|
"cache-control": "private, no-store"
|
|
123
173
|
} });
|
|
124
174
|
});
|
|
@@ -270,6 +320,8 @@ const MAX_SQL_ROWS = 1e3;
|
|
|
270
320
|
*/
|
|
271
321
|
function readOnlySqlViolation(query) {
|
|
272
322
|
const bare = query.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, "\"\"");
|
|
323
|
+
const semicolon = bare.indexOf(";");
|
|
324
|
+
if (semicolon !== -1 && bare.slice(semicolon + 1).trim() !== "") return "multiple statements are not allowed in read-only mode";
|
|
273
325
|
const first = /^\s*([A-Za-z]+)/.exec(bare)?.[1]?.toUpperCase();
|
|
274
326
|
if (first === void 0) return "empty statement";
|
|
275
327
|
if (![
|
|
@@ -332,8 +384,21 @@ function dbRouter(config) {
|
|
|
332
384
|
error: "invalid_query",
|
|
333
385
|
detail: "expected { query: string, params?: (string|number|boolean|null)[], readonly?: boolean }"
|
|
334
386
|
}, 400);
|
|
387
|
+
const verdict = await config.guard?.(c, {
|
|
388
|
+
kind: "sql",
|
|
389
|
+
readonly: body.readonly
|
|
390
|
+
}, resolved);
|
|
391
|
+
if (verdict instanceof Response) return verdict;
|
|
392
|
+
const forced = verdict?.readonly === true;
|
|
393
|
+
if (forced) {
|
|
394
|
+
const violation = readOnlySqlViolation(body.query);
|
|
395
|
+
if (violation) return c.json({
|
|
396
|
+
error: "browser_db_access",
|
|
397
|
+
detail: `${violation}. ${verdict.refusal}`
|
|
398
|
+
}, 403);
|
|
399
|
+
}
|
|
335
400
|
if (body.readonly === void 0) {
|
|
336
|
-
const result = await store.query(body.query, params).catch((err) => err);
|
|
401
|
+
const result = await store.query(body.query, params, forced ? { readonly: true } : void 0).catch((err) => err);
|
|
337
402
|
if (result instanceof Error) return c.json({
|
|
338
403
|
error: "sql_error",
|
|
339
404
|
detail: result.message
|
|
@@ -347,7 +412,7 @@ function dbRouter(config) {
|
|
|
347
412
|
detail: violation
|
|
348
413
|
}, 400);
|
|
349
414
|
}
|
|
350
|
-
const result = await store.query(body.query, params, { readonly: body.readonly }).catch((err) => err);
|
|
415
|
+
const result = await store.query(body.query, params, { readonly: body.readonly || forced }).catch((err) => err);
|
|
351
416
|
if (result instanceof Error) return c.json({
|
|
352
417
|
error: "sql_error",
|
|
353
418
|
detail: result.message
|
|
@@ -364,6 +429,8 @@ function dbRouter(config) {
|
|
|
364
429
|
db.get("/db/schema", async (c) => {
|
|
365
430
|
const resolved = await resolve(c);
|
|
366
431
|
if (resolved instanceof Response) return resolved;
|
|
432
|
+
const verdict = await config.guard?.(c, { kind: "schema" }, resolved);
|
|
433
|
+
if (verdict instanceof Response) return verdict;
|
|
367
434
|
const schema = await introspectSchema(resolved.store, resolved.dialect.name).catch((err) => err);
|
|
368
435
|
if (schema instanceof Error) return c.json({
|
|
369
436
|
error: "sql_error",
|
|
@@ -377,6 +444,11 @@ function dbRouter(config) {
|
|
|
377
444
|
const { store, dialect } = resolved;
|
|
378
445
|
const collection = c.req.param("collection");
|
|
379
446
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
447
|
+
const verdict = await config.guard?.(c, {
|
|
448
|
+
kind: "write",
|
|
449
|
+
collection
|
|
450
|
+
}, resolved);
|
|
451
|
+
if (verdict instanceof Response) return verdict;
|
|
380
452
|
const body = await c.req.json().catch(() => void 0);
|
|
381
453
|
if (!isPlainObject(body)) return c.json({
|
|
382
454
|
error: "invalid_document",
|
|
@@ -405,6 +477,11 @@ function dbRouter(config) {
|
|
|
405
477
|
const { store, dialect } = resolved;
|
|
406
478
|
const collection = c.req.param("collection");
|
|
407
479
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
480
|
+
const verdict = await config.guard?.(c, {
|
|
481
|
+
kind: "read",
|
|
482
|
+
collection
|
|
483
|
+
}, resolved);
|
|
484
|
+
if (verdict instanceof Response) return verdict;
|
|
408
485
|
const q = c.req.query();
|
|
409
486
|
try {
|
|
410
487
|
const limit = intParam(q.limit, "limit", 100, MAX_LIMIT);
|
|
@@ -457,6 +534,11 @@ function dbRouter(config) {
|
|
|
457
534
|
const { store, dialect } = resolved;
|
|
458
535
|
const collection = c.req.param("collection");
|
|
459
536
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
537
|
+
const verdict = await config.guard?.(c, {
|
|
538
|
+
kind: "read",
|
|
539
|
+
collection
|
|
540
|
+
}, resolved);
|
|
541
|
+
if (verdict instanceof Response) return verdict;
|
|
460
542
|
const get = dialect.get(collection, c.req.param("id"));
|
|
461
543
|
const row = (await store.query(get.sql, get.params)).results[0];
|
|
462
544
|
return c.json(row ? toDoc(row) : null);
|
|
@@ -467,6 +549,11 @@ function dbRouter(config) {
|
|
|
467
549
|
const { store, dialect } = resolved;
|
|
468
550
|
const collection = c.req.param("collection");
|
|
469
551
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
552
|
+
const verdict = await config.guard?.(c, {
|
|
553
|
+
kind: "write",
|
|
554
|
+
collection
|
|
555
|
+
}, resolved);
|
|
556
|
+
if (verdict instanceof Response) return verdict;
|
|
470
557
|
const body = await c.req.json().catch(() => void 0);
|
|
471
558
|
if (!isPlainObject(body)) return c.json({
|
|
472
559
|
error: "invalid_patch",
|
|
@@ -483,6 +570,11 @@ function dbRouter(config) {
|
|
|
483
570
|
const { store, dialect } = resolved;
|
|
484
571
|
const collection = c.req.param("collection");
|
|
485
572
|
if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
|
|
573
|
+
const verdict = await config.guard?.(c, {
|
|
574
|
+
kind: "write",
|
|
575
|
+
collection
|
|
576
|
+
}, resolved);
|
|
577
|
+
if (verdict instanceof Response) return verdict;
|
|
486
578
|
const del = dialect.del(collection, c.req.param("id"));
|
|
487
579
|
const result = await store.query(del.sql, del.params);
|
|
488
580
|
return c.json({ deleted: (result.meta.changes ?? 0) > 0 });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-
|
|
2
|
-
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
5
|
//#region src/staging.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
const tokenCommand = defineCommand({
|
|
5
5
|
meta: {
|
|
@@ -21,9 +21,10 @@ const tokenCommand = defineCommand({
|
|
|
21
21
|
},
|
|
22
22
|
run: reportErrors("token create", async ({ args }) => {
|
|
23
23
|
const { slug } = resolveProject(args);
|
|
24
|
-
const { token } = await apiRequest("POST", `/projects/${slug}/deploy-tokens`, { name: args.name });
|
|
24
|
+
const { token, idle_days } = await apiRequest("POST", `/projects/${slug}/deploy-tokens`, { name: args.name });
|
|
25
25
|
console.log(`Deploy token for ${slug} (shown once — store it now):\n`);
|
|
26
26
|
console.log(` ${token}\n`);
|
|
27
|
+
console.log(`It belongs to the project and stops working after ${idle_days} days without a deploy.`);
|
|
27
28
|
console.log("Add it to the GitHub repo as secret CANOPY_DEPLOY_TOKEN");
|
|
28
29
|
console.log("(gh secret set CANOPY_DEPLOY_TOKEN, or repo Settings → Secrets).");
|
|
29
30
|
console.log("Pushes to main then deploy via .github/workflows/canopy-deploy.yml.");
|
|
@@ -42,7 +43,10 @@ const tokenCommand = defineCommand({
|
|
|
42
43
|
console.log(`No deploy tokens for ${slug} — create one with \`canopy token create\`.`);
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
|
-
for (const t of tokens)
|
|
46
|
+
for (const t of tokens) {
|
|
47
|
+
const used = t.last_used_at ? `last used ${formatDateTime(t.last_used_at)}` : "never used";
|
|
48
|
+
console.log(`${t.id} ${t.name ?? "(unnamed)"} created ${formatDateTime(t.created_at)} by ${t.created_by ?? "?"} ${used}`);
|
|
49
|
+
}
|
|
46
50
|
})
|
|
47
51
|
}),
|
|
48
52
|
revoke: defineCommand({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as PACKAGE_NAME } from "./version-check-CcsA1QjI.mjs";
|
|
2
|
-
import { r as reportErrors } from "./ui
|
|
2
|
+
import { r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -112,7 +112,7 @@ const upgradeCommand = defineCommand({
|
|
|
112
112
|
const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
|
|
113
113
|
const command = pmCommand(pm);
|
|
114
114
|
const cmd = installArgs(pm, spec);
|
|
115
|
-
const { skillInstalled } = await import("./skill-
|
|
115
|
+
const { skillInstalled } = await import("./skill-Dt49jdUT.mjs");
|
|
116
116
|
const { homedir } = await import("node:os");
|
|
117
117
|
const refreshGlobal = skillInstalled(homedir());
|
|
118
118
|
const refreshRepo = process.cwd() !== homedir() && skillInstalled(process.cwd());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui-CUNkis89.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
//#region src/usage.ts
|
|
5
5
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mediamonks/canopy-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.24",
|
|
4
4
|
"description": "Canopy CLI — create, develop and deploy Canopy projects.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"canopy": "./dist/index.mjs"
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"esbuild": "^0.28.1",
|
|
21
21
|
"hono": "^4.8.0",
|
|
22
22
|
"vite-server": "npm:vite@^7",
|
|
23
|
-
"@mediamonks/canopy
|
|
24
|
-
"@mediamonks/canopy": "0.0.
|
|
23
|
+
"@mediamonks/canopy": "0.0.7",
|
|
24
|
+
"@mediamonks/canopy-data-plane": "0.0.4"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.1",
|
package/dist/db-TaSLpQZ5.mjs
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-BFaWfSVu.mjs";
|
|
2
|
-
import { a as unwrap, d as withContext, i as requireTty, r as reportErrors, s as apiDataRequest } from "./ui--atbZ-TM.mjs";
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Bg9FEGFh.mjs";
|
|
4
|
-
import { defineCommand } from "citty";
|
|
5
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
-
import * as p from "@clack/prompts";
|
|
7
|
-
//#region src/db.ts
|
|
8
|
-
/**
|
|
9
|
-
* canopy db pull | snapshot | snapshots | restore — data safety from the
|
|
10
|
-
* terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
|
|
11
|
-
*
|
|
12
|
-
* pull download a fresh SQL dump of the project database
|
|
13
|
-
* snapshot store a dump server-side (restorable from CLI or dashboard)
|
|
14
|
-
* snapshots list stored snapshots
|
|
15
|
-
* restore replace ALL project data from a snapshot or a local .sql file
|
|
16
|
-
* migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
|
|
17
|
-
*
|
|
18
|
-
* The project comes from canopy.json in the current directory, or
|
|
19
|
-
* --project <slug>.
|
|
20
|
-
*/
|
|
21
|
-
/** Shared flag: operate on the staging channel's database (docs/14). */
|
|
22
|
-
const stagingFlagArgs = { staging: {
|
|
23
|
-
type: "boolean",
|
|
24
|
-
default: false,
|
|
25
|
-
description: "Target the staging channel's database instead of live"
|
|
26
|
-
} };
|
|
27
|
-
const channelOf = (args) => args.staging ? "staging" : "live";
|
|
28
|
-
const channelNote = (args) => args.staging ? " [staging]" : "";
|
|
29
|
-
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
30
|
-
const dbCommand = defineCommand({
|
|
31
|
-
meta: {
|
|
32
|
-
name: "db",
|
|
33
|
-
description: "Data tools: pull, snapshot, snapshots, restore, migrate"
|
|
34
|
-
},
|
|
35
|
-
subCommands: {
|
|
36
|
-
pull: defineCommand({
|
|
37
|
-
meta: {
|
|
38
|
-
name: "pull",
|
|
39
|
-
description: "Download a fresh SQL dump of the project database"
|
|
40
|
-
},
|
|
41
|
-
args: {
|
|
42
|
-
...projectFlagArgs,
|
|
43
|
-
...stagingFlagArgs,
|
|
44
|
-
out: {
|
|
45
|
-
type: "string",
|
|
46
|
-
description: "Output file (defaults to <slug>-<timestamp>.sql)"
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
run: reportErrors("db pull", async ({ args }) => {
|
|
50
|
-
const { slug } = resolveProject(args);
|
|
51
|
-
const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
|
|
52
|
-
const spin = p.spinner();
|
|
53
|
-
spin.start(`Exporting ${slug}${channelNote(args)}…`);
|
|
54
|
-
try {
|
|
55
|
-
const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
|
|
56
|
-
const download = await fetch(url);
|
|
57
|
-
if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
|
|
58
|
-
const sql = Buffer.from(await download.arrayBuffer());
|
|
59
|
-
writeFileSync(out, sql);
|
|
60
|
-
spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
|
|
61
|
-
} catch (err) {
|
|
62
|
-
spin.error("Export failed.");
|
|
63
|
-
throw err;
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
}),
|
|
67
|
-
snapshot: defineCommand({
|
|
68
|
-
meta: {
|
|
69
|
-
name: "snapshot",
|
|
70
|
-
description: "Store a dump server-side (restorable anytime)"
|
|
71
|
-
},
|
|
72
|
-
args: {
|
|
73
|
-
...projectFlagArgs,
|
|
74
|
-
...stagingFlagArgs
|
|
75
|
-
},
|
|
76
|
-
run: reportErrors("db snapshot", async ({ args }) => {
|
|
77
|
-
const { slug } = resolveProject(args);
|
|
78
|
-
const spin = p.spinner();
|
|
79
|
-
spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
|
|
80
|
-
let created;
|
|
81
|
-
try {
|
|
82
|
-
({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
|
|
83
|
-
} catch (err) {
|
|
84
|
-
spin.error("Snapshot failed.");
|
|
85
|
-
throw err;
|
|
86
|
-
}
|
|
87
|
-
spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
|
|
88
|
-
console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
|
|
89
|
-
})
|
|
90
|
-
}),
|
|
91
|
-
snapshots: defineCommand({
|
|
92
|
-
meta: {
|
|
93
|
-
name: "snapshots",
|
|
94
|
-
description: "List stored snapshots"
|
|
95
|
-
},
|
|
96
|
-
args: {
|
|
97
|
-
...projectFlagArgs,
|
|
98
|
-
...stagingFlagArgs
|
|
99
|
-
},
|
|
100
|
-
run: reportErrors("db snapshots", async ({ args }) => {
|
|
101
|
-
const { slug } = resolveProject(args);
|
|
102
|
-
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
103
|
-
if (snapshots.length === 0) {
|
|
104
|
-
console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
|
|
108
|
-
})
|
|
109
|
-
}),
|
|
110
|
-
restore: defineCommand({
|
|
111
|
-
meta: {
|
|
112
|
-
name: "restore",
|
|
113
|
-
description: "Replace ALL project data from a snapshot or a local .sql file"
|
|
114
|
-
},
|
|
115
|
-
args: {
|
|
116
|
-
...projectFlagArgs,
|
|
117
|
-
...stagingFlagArgs,
|
|
118
|
-
file: {
|
|
119
|
-
type: "positional",
|
|
120
|
-
description: "Local .sql dump to restore from",
|
|
121
|
-
required: false
|
|
122
|
-
},
|
|
123
|
-
snapshot: {
|
|
124
|
-
type: "string",
|
|
125
|
-
description: "Server-side snapshot key to restore from"
|
|
126
|
-
},
|
|
127
|
-
yes: {
|
|
128
|
-
type: "boolean",
|
|
129
|
-
default: false,
|
|
130
|
-
description: "Skip the type-the-slug confirmation"
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
run: reportErrors("db restore", async ({ args }) => {
|
|
134
|
-
const { slug } = resolveProject(args);
|
|
135
|
-
const { file } = args;
|
|
136
|
-
let snapshotKey = args.snapshot;
|
|
137
|
-
if (!snapshotKey && !file) {
|
|
138
|
-
requireTty("pass a local .sql dump or --snapshot <key>.");
|
|
139
|
-
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
140
|
-
if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
|
|
141
|
-
snapshotKey = unwrap(await p.select({
|
|
142
|
-
message: `Snapshot to restore into ${slug}`,
|
|
143
|
-
options: snapshots.map((snapshot) => ({
|
|
144
|
-
value: snapshot.key,
|
|
145
|
-
label: snapshot.key,
|
|
146
|
-
hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
|
|
147
|
-
}))
|
|
148
|
-
}));
|
|
149
|
-
}
|
|
150
|
-
if (!args.yes) {
|
|
151
|
-
requireTty("pass --yes to restore without confirmation.");
|
|
152
|
-
if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}"${channelNote(args)} with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
|
|
153
|
-
p.cancel("Aborted.");
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
|
|
158
|
-
if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }, void 0, channelOf(args)), restoreContext);
|
|
159
|
-
else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql", channelOf(args)), restoreContext);
|
|
160
|
-
p.log.success(`Restored ${slug}${channelNote(args)}.`);
|
|
161
|
-
})
|
|
162
|
-
}),
|
|
163
|
-
migrate: defineCommand({
|
|
164
|
-
meta: {
|
|
165
|
-
name: "migrate",
|
|
166
|
-
description: "Apply pending schema migrations to the project database"
|
|
167
|
-
},
|
|
168
|
-
args: {
|
|
169
|
-
...projectFlagArgs,
|
|
170
|
-
...stagingFlagArgs,
|
|
171
|
-
plan: {
|
|
172
|
-
type: "boolean",
|
|
173
|
-
default: false,
|
|
174
|
-
description: "Dry run: list pending migrations without applying anything"
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
run: reportErrors("db migrate", async ({ args }) => {
|
|
178
|
-
const { dir, slug } = resolveProject(args);
|
|
179
|
-
const files = readMigrationFiles(dir);
|
|
180
|
-
if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
|
|
181
|
-
if (args.plan) {
|
|
182
|
-
const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
|
|
183
|
-
if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
|
|
184
|
-
else for (const name of result.pending) console.log(`pending ${name}`);
|
|
185
|
-
for (const line of describeMigrateResult(result)) console.log(line);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const spin = p.spinner();
|
|
189
|
-
spin.start(`Migrating ${slug}${channelNote(args)}…`);
|
|
190
|
-
let result;
|
|
191
|
-
try {
|
|
192
|
-
result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
|
|
193
|
-
} catch (err) {
|
|
194
|
-
spin.error("Migrate failed.");
|
|
195
|
-
throw err;
|
|
196
|
-
}
|
|
197
|
-
spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
|
|
198
|
-
for (const line of describeMigrateResult(result)) console.log(` ${line}`);
|
|
199
|
-
})
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
//#endregion
|
|
204
|
-
export { dbCommand };
|
package/dist/dev-D7v7WK9O.mjs
DELETED