@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,6 @@
1
- import { a as projectFlagArgs, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-DThlyHgo.mjs";
1
+ import { c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
2
+ import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-D8HxX0Ng.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-I1adj0z2.mjs";
3
4
  import { defineCommand } from "citty";
4
5
  import { readFileSync, writeFileSync } from "node:fs";
5
6
  import * as p from "@clack/prompts";
@@ -12,15 +13,24 @@ import * as p from "@clack/prompts";
12
13
  * snapshot store a dump server-side (restorable from CLI or dashboard)
13
14
  * snapshots list stored snapshots
14
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)
15
17
  *
16
18
  * The project comes from canopy.json in the current directory, or
17
19
  * --project <slug>.
18
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]" : "";
19
29
  const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
20
30
  const dbCommand = defineCommand({
21
31
  meta: {
22
32
  name: "db",
23
- description: "Data tools: pull, snapshot, snapshots, restore"
33
+ description: "Data tools: pull, snapshot, snapshots, restore, migrate"
24
34
  },
25
35
  subCommands: {
26
36
  pull: defineCommand({
@@ -30,6 +40,7 @@ const dbCommand = defineCommand({
30
40
  },
31
41
  args: {
32
42
  ...projectFlagArgs,
43
+ ...stagingFlagArgs,
33
44
  out: {
34
45
  type: "string",
35
46
  description: "Output file (defaults to <slug>-<timestamp>.sql)"
@@ -39,9 +50,9 @@ const dbCommand = defineCommand({
39
50
  const { slug } = resolveProject(args);
40
51
  const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
41
52
  const spin = p.spinner();
42
- spin.start(`Exporting ${slug}…`);
53
+ spin.start(`Exporting ${slug}${channelNote(args)}…`);
43
54
  try {
44
- const { url } = await apiDataRequest("POST", "/db/export", slug);
55
+ const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
45
56
  const download = await fetch(url);
46
57
  if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
47
58
  const sql = Buffer.from(await download.arrayBuffer());
@@ -58,14 +69,17 @@ const dbCommand = defineCommand({
58
69
  name: "snapshot",
59
70
  description: "Store a dump server-side (restorable anytime)"
60
71
  },
61
- args: projectFlagArgs,
72
+ args: {
73
+ ...projectFlagArgs,
74
+ ...stagingFlagArgs
75
+ },
62
76
  run: reportErrors("db snapshot", async ({ args }) => {
63
77
  const { slug } = resolveProject(args);
64
78
  const spin = p.spinner();
65
- spin.start(`Snapshotting ${slug}…`);
79
+ spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
66
80
  let created;
67
81
  try {
68
- ({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug));
82
+ ({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
69
83
  } catch (err) {
70
84
  spin.error("Snapshot failed.");
71
85
  throw err;
@@ -79,10 +93,13 @@ const dbCommand = defineCommand({
79
93
  name: "snapshots",
80
94
  description: "List stored snapshots"
81
95
  },
82
- args: projectFlagArgs,
96
+ args: {
97
+ ...projectFlagArgs,
98
+ ...stagingFlagArgs
99
+ },
83
100
  run: reportErrors("db snapshots", async ({ args }) => {
84
101
  const { slug } = resolveProject(args);
85
- const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug);
102
+ const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
86
103
  if (snapshots.length === 0) {
87
104
  console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
88
105
  return;
@@ -97,6 +114,7 @@ const dbCommand = defineCommand({
97
114
  },
98
115
  args: {
99
116
  ...projectFlagArgs,
117
+ ...stagingFlagArgs,
100
118
  file: {
101
119
  type: "positional",
102
120
  description: "Local .sql dump to restore from",
@@ -118,7 +136,7 @@ const dbCommand = defineCommand({
118
136
  let snapshotKey = args.snapshot;
119
137
  if (!snapshotKey && !file) {
120
138
  requireTty("pass a local .sql dump or --snapshot <key>.");
121
- const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug);
139
+ const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
122
140
  if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
123
141
  snapshotKey = unwrap(await p.select({
124
142
  message: `Snapshot to restore into ${slug}`,
@@ -131,15 +149,53 @@ const dbCommand = defineCommand({
131
149
  }
132
150
  if (!args.yes) {
133
151
  requireTty("pass --yes to restore without confirmation.");
134
- if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}" with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
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) {
135
153
  p.cancel("Aborted.");
136
154
  return;
137
155
  }
138
156
  }
139
157
  const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
140
- if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }), restoreContext);
141
- else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql"), restoreContext);
142
- p.log.success(`Restored ${slug}.`);
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}`);
143
199
  })
144
200
  })
145
201
  }
@@ -1,5 +1,6 @@
1
- import { a as projectFlagArgs, c as tryReadCanopyJson, i as projectDirArgs, n as siteConfig, o as projectUrl, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, s as apiDownload } from "./ui-DThlyHgo.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
+ import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, s as apiDownload } from "./ui-D8HxX0Ng.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-I1adj0z2.mjs";
3
4
  import { defineCommand } from "citty";
4
5
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
@@ -46,6 +47,10 @@ async function bundleWorker(projectDir, entry) {
46
47
  *
47
48
  * Reads canopy.json in the project root:
48
49
  * { "slug": "my-app", "dist": ".", "build": "npm run build" }
50
+ *
51
+ * A "migrations" dir in canopy.json adds a schema step (docs/13): pending
52
+ * migrations apply after the bundle is staged and before the version flips —
53
+ * schema first, then code. A migration failure aborts the deploy.
49
54
  */
50
55
  /** Must match apps/api/src/routes/deploys.ts WORKER_BUNDLE_PATH. */
51
56
  const WORKER_BUNDLE_PATH = "_canopy/worker.mjs";
@@ -97,12 +102,29 @@ const deployCommand = defineCommand({
97
102
  default: true,
98
103
  description: "Run the canopy.json build command first",
99
104
  negativeDescription: "Skip the build, deploy the output directory as-is"
105
+ },
106
+ migrate: {
107
+ type: "boolean",
108
+ default: true,
109
+ description: "Apply pending schema migrations before the version goes live",
110
+ negativeDescription: "Skip migrations, deploy the code only"
111
+ },
112
+ staging: {
113
+ type: "boolean",
114
+ default: false,
115
+ description: "Deploy to the project's staging channel (created on first use, database cloned from live)"
100
116
  }
101
117
  },
102
118
  run: reportErrors("deploy", async ({ args }) => {
103
119
  const { dir, slug } = resolveProject(args);
120
+ const channel = args.staging ? "staging" : "live";
104
121
  const config = tryReadCanopyJson(dir) ?? {};
105
122
  if (config.build && args.build) await runBuild(config.build, dir);
123
+ const migrations = args.migrate ? readMigrationFiles(dir) : void 0;
124
+ if (migrations !== void 0 && config.dialect) try {
125
+ const { dialect } = await apiDataRequest("GET", "/platform", slug);
126
+ if (dialect !== config.dialect) p.log.warn(`canopy.json pins dialect "${config.dialect}" but this site's database speaks ${dialect} — the migrations below were probably written for a different target.`);
127
+ } catch {}
106
128
  const distDir = resolve(dir, config.dist ?? ".");
107
129
  let paths;
108
130
  try {
@@ -138,22 +160,97 @@ const deployCommand = defineCommand({
138
160
  const source = process.env["GITHUB_ACTIONS"] ? { "x-canopy-source": "github-action" } : {};
139
161
  const spin = p.spinner();
140
162
  const workerNote = workerBytes ? " + worker" : "";
141
- spin.start(`Deploying ${files.length} files${workerNote} (${(total / 1024).toFixed(0)} kB) → ${slug}`);
163
+ const channelNote = channel === "staging" ? " [staging]" : "";
164
+ spin.start(`Deploying ${files.length} files${workerNote} (${(total / 1024).toFixed(0)} kB) → ${slug}${channelNote}`);
142
165
  let result;
166
+ let migrateResult;
167
+ let stagingCreated = false;
143
168
  try {
144
- const { upload } = await withContext(apiRequest("POST", `/projects/${slug}/deploys`, { worker: workerBytes !== null }, void 0, source), { action: `deploy "${slug}"` });
169
+ const { upload } = await withContext(apiRequest("POST", `/projects/${slug}/deploys`, {
170
+ worker: workerBytes !== null,
171
+ channel
172
+ }, void 0, source), { action: `deploy "${slug}"` });
173
+ stagingCreated = upload.stagingCreated === true;
145
174
  const uploads = [...files, ...workerBytes ? [{
146
175
  path: WORKER_BUNDLE_PATH,
147
176
  data: workerBytes
148
177
  }] : []];
149
178
  await uploadFiles(upload.grant, uploads);
179
+ if (migrations !== void 0 && migrations.length > 0) {
180
+ spin.message(`Applying schema migrations → ${slug}${channelNote}`);
181
+ try {
182
+ migrateResult = await withContext(migrateProject(slug, migrations, false, channel), { action: `migrate "${slug}"${channelNote}` });
183
+ } catch (err) {
184
+ spin.error(channel === "staging" ? "Deploy aborted — migrations failed on staging; live is unaffected." : "Deploy aborted — migrations failed; the live version is unchanged.");
185
+ throw err;
186
+ }
187
+ }
150
188
  ({deploy: result} = await withContext(apiRequest("POST", `/projects/${slug}/deploys/${upload.version}/complete`, { grant: upload.grant }, void 0, source), { action: `deploy "${slug}"` }));
151
189
  } catch (err) {
152
190
  spin.error("Deploy failed.");
153
191
  throw err;
154
192
  }
155
193
  const backend = result.worker?.serviceUrl ? ` · backend ${result.worker.serviceUrl}` : "";
156
- spin.stop(`Live: ${projectUrl(slug)} (v${result.version})${backend}`);
194
+ if (channel === "staging") {
195
+ spin.stop(`Staging: ${stagingUrl(slug)} (v${result.version})${backend}`);
196
+ if (stagingCreated) console.log(" staging channel created — database cloned from live");
197
+ console.log(` happy with it? \`canopy promote\` makes v${result.version} live`);
198
+ } else spin.stop(`Live: ${projectUrl(slug)} (v${result.version})${backend}`);
199
+ if (migrateResult !== void 0) for (const line of describeMigrateResult(migrateResult)) console.log(` ${line}`);
200
+ })
201
+ });
202
+ const promoteCommand = defineCommand({
203
+ meta: {
204
+ name: "promote",
205
+ description: "Make the staged version live (migrations replay against live first)"
206
+ },
207
+ args: {
208
+ version: {
209
+ type: "positional",
210
+ description: "Version to promote, e.g. v3 (defaults to the staged version)",
211
+ required: false
212
+ },
213
+ ...projectFlagArgs,
214
+ dir: {
215
+ type: "string",
216
+ description: "Project directory (defaults to the current directory)"
217
+ },
218
+ yes: {
219
+ type: "boolean",
220
+ default: false,
221
+ description: "Skip the confirmation prompt"
222
+ }
223
+ },
224
+ run: reportErrors("promote", async ({ args }) => {
225
+ const { slug } = resolveProject(args);
226
+ let target;
227
+ if (args.version !== void 0) {
228
+ if (!/^v?\d+$/.test(args.version)) throw new Error(`Invalid version "${args.version}" — expected a number like 3 or v3.`);
229
+ target = Number(args.version.replace(/^v/, ""));
230
+ } else {
231
+ const { staging } = await apiRequest("GET", `/projects/${slug}/staging`);
232
+ if (!staging.provisioned || staging.version === null) throw new Error(`Nothing staged for "${slug}" — run \`canopy deploy --staging\` first, or pass a version explicitly.`);
233
+ target = staging.version;
234
+ }
235
+ if (!args.yes && process.stdin.isTTY) {
236
+ if (!unwrap(await p.confirm({ message: `Promote v${target} of "${slug}" to live? Pending migrations replay against the live database first (snapshot taken automatically).` }))) {
237
+ p.cancel("Aborted.");
238
+ return;
239
+ }
240
+ }
241
+ const spin = p.spinner();
242
+ spin.start(`Promoting ${slug} v${target} → live`);
243
+ let result;
244
+ try {
245
+ ({live: result} = await withContext(apiRequest("POST", `/projects/${slug}/deploys/${target}/promote`), { action: `promote "${slug}" v${target}` }));
246
+ } catch (err) {
247
+ spin.error("Promote failed — the live version is unchanged.");
248
+ throw err;
249
+ }
250
+ const backend = result.worker?.serviceUrl ? ` · backend ${result.worker.serviceUrl}` : "";
251
+ spin.stop(`Live: ${projectUrl(slug)} (v${result.version}, was v${result.previous ?? "?"})${backend}`);
252
+ for (const name of result.migrationsApplied) console.log(` applied ${name}`);
253
+ if (result.snapshot !== void 0) console.log(` pre-promote snapshot: ${result.snapshot} (canopy db restore --snapshot …)`);
157
254
  })
158
255
  });
159
256
  const UPLOAD_CONCURRENCY = 8;
@@ -191,7 +288,7 @@ async function pickVersion(deploys, live, message, initial) {
191
288
  const rollbackCommand = defineCommand({
192
289
  meta: {
193
290
  name: "rollback",
194
- description: "Make an earlier deploy live again (pick from history)"
291
+ description: "Make an earlier deploy live again, backend included (pick from history)"
195
292
  },
196
293
  args: {
197
294
  version: {
@@ -224,7 +321,8 @@ const rollbackCommand = defineCommand({
224
321
  }
225
322
  console.log(`Rolling back ${slug} to v${target}`);
226
323
  const { live: result } = await apiRequest("POST", `/projects/${slug}/deploys/${target}/rollback`);
227
- console.log(`Live: ${projectUrl(slug)} (v${result.version}, was v${result.previous ?? "?"})`);
324
+ const worker = result.worker ? ` · worker v${result.worker.version}` : "";
325
+ console.log(`Live: ${projectUrl(slug)} (v${result.version}, was v${result.previous ?? "?"})${worker}`);
228
326
  })
229
327
  });
230
328
  const downloadCommand = defineCommand({
@@ -275,4 +373,4 @@ const downloadCommand = defineCommand({
275
373
  })
276
374
  });
277
375
  //#endregion
278
- export { deployCommand, downloadCommand, rollbackCommand };
376
+ export { deployCommand, downloadCommand, promoteCommand, rollbackCommand };
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-DvyKHe2d.mjs";
2
+ export { devCommand };
@@ -1,5 +1,6 @@
1
- import { i as projectDirArgs, n as siteConfig, s as resolveProject } from "./site-BJjoePQS.mjs";
2
- import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-DThlyHgo.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
+ import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-D8HxX0Ng.mjs";
3
+ import { r as readMigrationFiles } from "./migrations-I1adj0z2.mjs";
3
4
  import { createRequire } from "node:module";
4
5
  import { defineCommand } from "citty";
5
6
  import { join } from "node:path";
@@ -86,21 +87,34 @@ async function devRemote(flags, slug) {
86
87
  const server = await startVite(flags, { proxy: { "/_api": {
87
88
  target: apiUrl,
88
89
  changeOrigin: true,
89
- headers: { authorization: `Bearer ${token}` },
90
+ headers: {
91
+ authorization: `Bearer ${token}`,
92
+ ...flags.staging ? { "x-canopy-channel": "staging" } : {}
93
+ },
90
94
  rewrite: rewriteApiPath
91
95
  } } });
92
- console.log(`\n ${slug} — remote-first dev against LIVE project data`);
93
- console.log(" (worried about a risky change? `canopy db snapshot` first, or work");
94
- console.log(" on a local copy instead: `canopy dev --local --pull`)\n");
96
+ if (flags.staging) {
97
+ console.log(`\n ${slug} — remote-first dev against the STAGING channel's data`);
98
+ console.log(" (live data is untouched; `canopy staging sync` re-clones it from live)");
99
+ } else {
100
+ console.log(`\n ${slug} — remote-first dev against LIVE project data`);
101
+ console.log(" (worried about a risky change? `canopy db snapshot` first, or work");
102
+ console.log(" on a local copy instead: `canopy dev --local --pull`)");
103
+ }
104
+ if (tryReadCanopyJson(flags.dir)?.migrations && !flags.staging) {
105
+ console.log(" migrations apply on `canopy deploy`, never from dev — use --local");
106
+ console.log(" while the schema is in flux");
107
+ }
108
+ console.log("");
95
109
  server.printUrls();
96
110
  console.log(`\n Dev token expires ${expires_at} — restart canopy dev after that.`);
97
111
  }
98
112
  async function devLocal(flags, slug) {
99
113
  let dump;
100
114
  if (flags.pull) {
101
- const { fetchProjectDump } = await import("./local-C5-GWJcx.mjs");
102
- console.log(`Pulling a fresh export of ${slug}…`);
103
- dump = await fetchProjectDump(slug);
115
+ const { fetchProjectDump } = await import("./local-C47tmhl7.mjs");
116
+ console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
117
+ dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
104
118
  }
105
119
  let remote;
106
120
  try {
@@ -113,16 +127,22 @@ async function devLocal(flags, slug) {
113
127
  const reason = err instanceof ApiError ? err.explain().why : String(err);
114
128
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
115
129
  }
116
- const { createSandbox } = await import("./local-C5-GWJcx.mjs");
130
+ const { createSandbox } = await import("./local-C47tmhl7.mjs");
117
131
  const sandbox = await createSandbox({
118
132
  slug,
119
133
  persistDir: join(flags.dir, ".canopy", "local"),
120
- email: siteConfig().email
134
+ email: siteConfig().email,
135
+ dialect: flags.dialect
121
136
  });
122
137
  if (dump !== void 0) {
123
138
  await sandbox.applyDump(dump);
124
139
  console.log(`Hydrated the sandbox (${(dump.length / 1024).toFixed(1)} kB of SQL).`);
125
140
  }
141
+ const migrationFiles = readMigrationFiles(flags.dir);
142
+ if (migrationFiles !== void 0 && migrationFiles.length > 0) {
143
+ const { applied } = await sandbox.applyMigrations(migrationFiles);
144
+ if (applied.length > 0) console.log(`Applied ${applied.length} pending migration(s): ${applied.join(", ")}`);
145
+ }
126
146
  const handleApi = async (req, res) => {
127
147
  const chunks = [];
128
148
  for await (const chunk of req) chunks.push(chunk);
@@ -170,8 +190,10 @@ async function devLocal(flags, slug) {
170
190
  apiServer.close();
171
191
  sandbox.dispose();
172
192
  });
173
- console.log(`\n ${slug} — LOCAL sandbox (PGlite + filesystem blobs in .canopy/local/)`);
193
+ const engine = flags.dialect === "sqlite" ? "SQLite" : "PGlite";
194
+ console.log(`\n ${slug} — LOCAL sandbox (${engine} + filesystem blobs in .canopy/local/)`);
174
195
  console.log(" data is yours alone: db and blob writes never reach the platform");
196
+ if (flags.dialect === void 0) console.log(" raw sql() speaks postgres here — on a D1 site? lock \"dialect\" in canopy.json");
175
197
  if (dump === void 0) console.log(" (want prod data? restart with --pull to hydrate from a fresh export)");
176
198
  console.log(" blobs are not pulled — the local blob store starts empty");
177
199
  console.log(remote ? " ai/services pass through to the platform (metered as usual)" : " ai/services answer 501 until you log in and restart");
@@ -192,24 +214,32 @@ const devCommand = defineCommand({
192
214
  local: {
193
215
  type: "boolean",
194
216
  default: false,
195
- description: "Local-data sandbox (PGlite + blobs under .canopy/local/) instead of live data"
217
+ description: "Local-data sandbox (database + blobs under .canopy/local/) instead of live data"
196
218
  },
197
219
  pull: {
198
220
  type: "boolean",
199
221
  default: false,
200
222
  description: "Hydrate the sandbox from a fresh prod export first (requires --local)"
223
+ },
224
+ staging: {
225
+ type: "boolean",
226
+ default: false,
227
+ description: "Point remote dev at the staging channel's data (or pull the staging dump with --local --pull)"
201
228
  }
202
229
  },
203
230
  run: reportErrors("dev", async ({ args }) => {
204
231
  const port = args.port === void 0 ? void 0 : Number(args.port);
205
232
  if (port !== void 0 && !Number.isInteger(port)) throw new Error(`Invalid --port=${args.port}`);
206
233
  if (args.pull && !args.local) throw new Error("--pull only makes sense with --local (remote dev already sees live data).");
234
+ if (args.staging && args.local && !args.pull) throw new Error("--staging with --local needs --pull (it selects which dump hydrates the sandbox).");
207
235
  const { dir, slug } = resolveProject(args);
208
236
  const flags = {
209
237
  dir,
210
238
  port,
211
239
  local: args.local,
212
- pull: args.pull
240
+ pull: args.pull,
241
+ staging: args.staging,
242
+ dialect: projectDialect(dir)
213
243
  };
214
244
  await (flags.local ? devLocal(flags, slug) : devRemote(flags, slug));
215
245
  })
package/dist/docs/ci.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: CI and deploy tokens
3
- summary: Push-to-main deploys using a project-scoped deploy token.
3
+ description: Push-to-main deploys using a project-scoped deploy token.
4
4
  ---
5
5
 
6
- # CI and deploy tokens
7
-
8
6
  Interactive `canopy login` is for humans at a terminal. For automated deploys
9
7
  (GitHub Actions, etc.) use a **deploy token**: a project-scoped credential that
10
8
  can deploy that one project and nothing else.
package/dist/docs/cli.md CHANGED
@@ -1,12 +1,11 @@
1
1
  ---
2
2
  title: CLI command reference
3
- summary: Every canopy command, its flags, and what it does.
3
+ description: Every canopy command, its flags, and what it does.
4
4
  ---
5
5
 
6
- # CLI command reference
7
-
8
6
  Run `canopy <command> --help` for the authoritative flags of any command. This
9
- is the overview.
7
+ is the one-page overview; the docs site's [CLI reference](https://docs.canopy.website/cli) section
8
+ has a full page per command.
10
9
 
11
10
  ## Auth
12
11
 
package/dist/docs/data.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Working with project data
3
- summary: Pulling, snapshotting, and restoring your project database.
3
+ description: Pulling, snapshotting, and restoring your project database.
4
4
  ---
5
5
 
6
- # Working with project data
7
-
8
6
  Each Canopy project gets its own database and blob storage (D1 and R2 on the
9
7
  Cloudflare target; Postgres and GCS on GCP). Your app reads and writes them
10
8
  through the Canopy SDK at runtime; the CLI gives you tools to manage the data
@@ -65,7 +63,10 @@ risky change.
65
63
 
66
64
  ## Restore — destructive
67
65
 
68
- Restoring **replaces ALL project data**:
66
+ > **Warning:** Restoring **replaces ALL project data**. Take a snapshot first — a snapshot taken seconds earlier
67
+ > is your undo button.
68
+
69
+ Restore from either source:
69
70
 
70
71
  ```bash
71
72
  canopy db restore backup.sql # from a local dump
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Deploying a project
3
- summary: How canopy deploy builds, uploads, and makes a version live.
3
+ description: How canopy deploy builds, uploads, and makes a version live.
4
4
  ---
5
5
 
6
- # Deploying a project
7
-
8
6
  From inside a project directory:
9
7
 
10
8
  ```bash
@@ -31,6 +29,19 @@ output directory (or the static files as-is for a `static` project):
31
29
  canopy deploy --no-build
32
30
  ```
33
31
 
32
+ ## Trying it on staging first
33
+
34
+ Nervous about a change — especially one with schema migrations or backend
35
+ code? Deploy it to the project's staging channel first, then promote the exact
36
+ same version once it checks out:
37
+
38
+ ```bash
39
+ canopy deploy --staging # own URL, own database (a copy of live)
40
+ canopy promote # make that version live
41
+ ```
42
+
43
+ See [Staging & promoting](https://docs.canopy.website/guides/staging).
44
+
34
45
  ## Rolling back
35
46
 
36
47
  Every deploy is kept, so you can instantly revert:
@@ -40,7 +51,8 @@ canopy rollback # pick a version from history
40
51
  canopy rollback v3 # make v3 live again
41
52
  ```
42
53
 
43
- Rollback re-points the live version at an existing deploy — it does not rebuild.
54
+ Rollback re-points the live version at an existing deploy — it does not
55
+ rebuild, and the project's backend rolls back with it.
44
56
 
45
57
  ## Downloading a version
46
58
 
package/dist/docs/dev.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Local development
3
- summary: Running a project locally against live or sandboxed data.
3
+ description: Running a project locally against live or sandboxed data.
4
4
  ---
5
5
 
6
- # Local development
7
-
8
6
  From inside a project directory:
9
7
 
10
8
  ```bash
@@ -20,14 +18,14 @@ develop against realistic state.
20
18
  ## Offline sandbox
21
19
 
22
20
  To develop without touching live data — for example on a plane, or before you
23
- trust a destructive change — use a local sandbox backed by PGlite (embedded
24
- Postgres) and a filesystem blob store:
21
+ trust a destructive change — use a local sandbox backed by an embedded database
22
+ and a filesystem blob store:
25
23
 
26
24
  ```bash
27
25
  canopy dev --local
28
26
  ```
29
27
 
30
- This creates local Postgres data and blobs under `.canopy/local/` in your
28
+ This creates a local database and blobs under `.canopy/local/` in your
31
29
  project. The sandbox starts empty.
32
30
 
33
31
  To seed the sandbox from a fresh copy of production data first:
@@ -38,11 +36,15 @@ canopy dev --local --pull
38
36
 
39
37
  `.canopy/local/` is disposable — delete it to reset the sandbox.
40
38
 
41
- One caveat: the sandbox database is Postgres, so raw `canopy.db.sql()` speaks
42
- the postgres dialect there, while your deployed project speaks whatever its
43
- platform target speaks (`canopy.db.dialect()` tells you — sqlite on
44
- Cloudflare). Collections (`canopy.db.collection(...)`) behave the same in both,
45
- so this only matters for hand-written SQL — see `canopy docs data`.
39
+ By default the sandbox runs PGlite (embedded Postgres), so raw
40
+ `canopy.db.sql()` speaks the postgres dialect there, while your deployed
41
+ project speaks whatever its platform target speaks (`canopy.db.dialect()`
42
+ tells you — sqlite on Cloudflare). Collections (`canopy.db.collection(...)`)
43
+ behave the same in both, so this only matters for hand-written SQL — see
44
+ `canopy docs data`. If your project's raw SQL targets a sqlite site, lock the
45
+ dialect in `canopy.json` (`"dialect": "sqlite"`) and the sandbox runs an
46
+ embedded SQLite engine instead, so the same SQL works locally and deployed —
47
+ see [canopy dev](https://docs.canopy.website/cli/dev).
46
48
 
47
49
  ## Which mode should an agent use?
48
50
 
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  title: Getting started
3
- summary: Install the CLI, sign in with OneLogin, and ship your first project.
3
+ description: Install the CLI, sign in with OneLogin, and ship your first project.
4
4
  ---
5
5
 
6
- # Getting started with Canopy
7
-
8
6
  Canopy is Monks' managed platform for vibecoded projects. You build a small web
9
7
  app, and Canopy hosts it, gives it a database, blob storage, and AI — no
10
8
  infrastructure to manage.