@mediamonks/canopy-cli 0.6.0 → 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 (28) hide show
  1. package/dist/{db-BpnjQUkF.mjs → db-BkUmbGgx.mjs} +35 -18
  2. package/dist/{deploy-DdSSL227.mjs → deploy-BcczXlSQ.mjs} +83 -12
  3. package/dist/dev-CvtoGFW0.mjs +2 -0
  4. package/dist/{dev-OxeYndd1.mjs → dev-DvyKHe2d.mjs} +27 -12
  5. package/dist/docs/deploy.md +15 -1
  6. package/dist/docs/migrations.md +17 -0
  7. package/dist/docs/staging.md +98 -0
  8. package/dist/{docs-Cept8t_3.mjs → docs-2cUEug69.mjs} +2 -2
  9. package/dist/index.mjs +22 -20
  10. package/dist/{local-D9OVEHDg.mjs → local-C47tmhl7.mjs} +4 -4
  11. package/dist/{login-Cb8D8a1Z.mjs → login-ByD0hcxu.mjs} +2 -2
  12. package/dist/{migrations-B0kJarjr.mjs → migrations-I1adj0z2.mjs} +5 -5
  13. package/dist/{open-BmgYBSdR.mjs → open-DD9oa-Ho.mjs} +11 -4
  14. package/dist/{package-D1wDsLSU.mjs → package-CDB-uxy3.mjs} +1 -1
  15. package/dist/{projects-CoGh8m9A.mjs → projects-CSH7KsPi.mjs} +2 -2
  16. package/dist/{site-DQnysZ3W.mjs → site-BUlQR117.mjs} +7 -1
  17. package/dist/skill/canopy/SKILL.md +6 -0
  18. package/dist/{skill-192EdixC.mjs → skill-DrWzmUMv.mjs} +2 -2
  19. package/dist/staging-CfF2-pHs.mjs +113 -0
  20. package/dist/templates/spa/AGENTS.md +4 -0
  21. package/dist/templates/static/AGENTS.md +4 -0
  22. package/dist/templates/worker/AGENTS.md +4 -0
  23. package/dist/{token-CWgp_Lqe.mjs → token-nieJV1w7.mjs} +2 -2
  24. package/dist/{ui-BhzMeKQl.mjs → ui-D8HxX0Ng.mjs} +3 -2
  25. package/dist/{upgrade-BrHSSg2e.mjs → upgrade-CH_jHkc7.mjs} +2 -2
  26. package/dist/{usage-CYQRLXwf.mjs → usage-qqPtkDtT.mjs} +2 -2
  27. package/package.json +3 -3
  28. package/dist/dev-DV5ud7AJ.mjs +0 -2
@@ -1,6 +1,6 @@
1
- import { c as resolveProject, o as projectFlagArgs } from "./site-DQnysZ3W.mjs";
2
- import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-BhzMeKQl.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-B0kJarjr.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";
4
4
  import { defineCommand } from "citty";
5
5
  import { readFileSync, writeFileSync } from "node:fs";
6
6
  import * as p from "@clack/prompts";
@@ -18,6 +18,14 @@ import * as p from "@clack/prompts";
18
18
  * The project comes from canopy.json in the current directory, or
19
19
  * --project <slug>.
20
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]" : "";
21
29
  const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
22
30
  const dbCommand = defineCommand({
23
31
  meta: {
@@ -32,6 +40,7 @@ const dbCommand = defineCommand({
32
40
  },
33
41
  args: {
34
42
  ...projectFlagArgs,
43
+ ...stagingFlagArgs,
35
44
  out: {
36
45
  type: "string",
37
46
  description: "Output file (defaults to <slug>-<timestamp>.sql)"
@@ -41,9 +50,9 @@ const dbCommand = defineCommand({
41
50
  const { slug } = resolveProject(args);
42
51
  const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
43
52
  const spin = p.spinner();
44
- spin.start(`Exporting ${slug}…`);
53
+ spin.start(`Exporting ${slug}${channelNote(args)}…`);
45
54
  try {
46
- const { url } = await apiDataRequest("POST", "/db/export", slug);
55
+ const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
47
56
  const download = await fetch(url);
48
57
  if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
49
58
  const sql = Buffer.from(await download.arrayBuffer());
@@ -60,14 +69,17 @@ const dbCommand = defineCommand({
60
69
  name: "snapshot",
61
70
  description: "Store a dump server-side (restorable anytime)"
62
71
  },
63
- args: projectFlagArgs,
72
+ args: {
73
+ ...projectFlagArgs,
74
+ ...stagingFlagArgs
75
+ },
64
76
  run: reportErrors("db snapshot", async ({ args }) => {
65
77
  const { slug } = resolveProject(args);
66
78
  const spin = p.spinner();
67
- spin.start(`Snapshotting ${slug}…`);
79
+ spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
68
80
  let created;
69
81
  try {
70
- ({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug));
82
+ ({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
71
83
  } catch (err) {
72
84
  spin.error("Snapshot failed.");
73
85
  throw err;
@@ -81,10 +93,13 @@ const dbCommand = defineCommand({
81
93
  name: "snapshots",
82
94
  description: "List stored snapshots"
83
95
  },
84
- args: projectFlagArgs,
96
+ args: {
97
+ ...projectFlagArgs,
98
+ ...stagingFlagArgs
99
+ },
85
100
  run: reportErrors("db snapshots", async ({ args }) => {
86
101
  const { slug } = resolveProject(args);
87
- const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug);
102
+ const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
88
103
  if (snapshots.length === 0) {
89
104
  console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
90
105
  return;
@@ -99,6 +114,7 @@ const dbCommand = defineCommand({
99
114
  },
100
115
  args: {
101
116
  ...projectFlagArgs,
117
+ ...stagingFlagArgs,
102
118
  file: {
103
119
  type: "positional",
104
120
  description: "Local .sql dump to restore from",
@@ -120,7 +136,7 @@ const dbCommand = defineCommand({
120
136
  let snapshotKey = args.snapshot;
121
137
  if (!snapshotKey && !file) {
122
138
  requireTty("pass a local .sql dump or --snapshot <key>.");
123
- const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug);
139
+ const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
124
140
  if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
125
141
  snapshotKey = unwrap(await p.select({
126
142
  message: `Snapshot to restore into ${slug}`,
@@ -133,15 +149,15 @@ const dbCommand = defineCommand({
133
149
  }
134
150
  if (!args.yes) {
135
151
  requireTty("pass --yes to restore without confirmation.");
136
- 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) {
137
153
  p.cancel("Aborted.");
138
154
  return;
139
155
  }
140
156
  }
141
157
  const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
142
- if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }), restoreContext);
143
- else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql"), restoreContext);
144
- 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)}.`);
145
161
  })
146
162
  }),
147
163
  migrate: defineCommand({
@@ -151,6 +167,7 @@ const dbCommand = defineCommand({
151
167
  },
152
168
  args: {
153
169
  ...projectFlagArgs,
170
+ ...stagingFlagArgs,
154
171
  plan: {
155
172
  type: "boolean",
156
173
  default: false,
@@ -162,17 +179,17 @@ const dbCommand = defineCommand({
162
179
  const files = readMigrationFiles(dir);
163
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).");
164
181
  if (args.plan) {
165
- const result = await withContext(migrateProject(slug, files, true), { action: `plan migrations for "${slug}"` });
182
+ const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
166
183
  if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
167
184
  else for (const name of result.pending) console.log(`pending ${name}`);
168
185
  for (const line of describeMigrateResult(result)) console.log(line);
169
186
  return;
170
187
  }
171
188
  const spin = p.spinner();
172
- spin.start(`Migrating ${slug}…`);
189
+ spin.start(`Migrating ${slug}${channelNote(args)}…`);
173
190
  let result;
174
191
  try {
175
- result = await withContext(migrateProject(slug, files), { action: `migrate "${slug}"` });
192
+ result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
176
193
  } catch (err) {
177
194
  spin.error("Migrate failed.");
178
195
  throw err;
@@ -1,6 +1,6 @@
1
- import { a as projectDirArgs, c as resolveProject, l as tryReadCanopyJson, n as siteConfig, o as projectFlagArgs, s as projectUrl } from "./site-DQnysZ3W.mjs";
2
- import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, s as apiDownload } from "./ui-BhzMeKQl.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-B0kJarjr.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";
4
4
  import { defineCommand } from "citty";
5
5
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
@@ -108,10 +108,16 @@ const deployCommand = defineCommand({
108
108
  default: true,
109
109
  description: "Apply pending schema migrations before the version goes live",
110
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)"
111
116
  }
112
117
  },
113
118
  run: reportErrors("deploy", async ({ args }) => {
114
119
  const { dir, slug } = resolveProject(args);
120
+ const channel = args.staging ? "staging" : "live";
115
121
  const config = tryReadCanopyJson(dir) ?? {};
116
122
  if (config.build && args.build) await runBuild(config.build, dir);
117
123
  const migrations = args.migrate ? readMigrationFiles(dir) : void 0;
@@ -154,22 +160,28 @@ const deployCommand = defineCommand({
154
160
  const source = process.env["GITHUB_ACTIONS"] ? { "x-canopy-source": "github-action" } : {};
155
161
  const spin = p.spinner();
156
162
  const workerNote = workerBytes ? " + worker" : "";
157
- 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}`);
158
165
  let result;
159
166
  let migrateResult;
167
+ let stagingCreated = false;
160
168
  try {
161
- 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;
162
174
  const uploads = [...files, ...workerBytes ? [{
163
175
  path: WORKER_BUNDLE_PATH,
164
176
  data: workerBytes
165
177
  }] : []];
166
178
  await uploadFiles(upload.grant, uploads);
167
179
  if (migrations !== void 0 && migrations.length > 0) {
168
- spin.message(`Applying schema migrations → ${slug}`);
180
+ spin.message(`Applying schema migrations → ${slug}${channelNote}`);
169
181
  try {
170
- migrateResult = await withContext(migrateProject(slug, migrations), { action: `migrate "${slug}"` });
182
+ migrateResult = await withContext(migrateProject(slug, migrations, false, channel), { action: `migrate "${slug}"${channelNote}` });
171
183
  } catch (err) {
172
- spin.error("Deploy aborted — migrations failed; the live version is unchanged.");
184
+ spin.error(channel === "staging" ? "Deploy aborted — migrations failed on staging; live is unaffected." : "Deploy aborted — migrations failed; the live version is unchanged.");
173
185
  throw err;
174
186
  }
175
187
  }
@@ -179,10 +191,68 @@ const deployCommand = defineCommand({
179
191
  throw err;
180
192
  }
181
193
  const backend = result.worker?.serviceUrl ? ` · backend ${result.worker.serviceUrl}` : "";
182
- 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}`);
183
199
  if (migrateResult !== void 0) for (const line of describeMigrateResult(migrateResult)) console.log(` ${line}`);
184
200
  })
185
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 …)`);
254
+ })
255
+ });
186
256
  const UPLOAD_CONCURRENCY = 8;
187
257
  /** PUT each file to /data/v1/uploads/<grant> — the grant is the credential. */
188
258
  async function uploadFiles(grant, files) {
@@ -218,7 +288,7 @@ async function pickVersion(deploys, live, message, initial) {
218
288
  const rollbackCommand = defineCommand({
219
289
  meta: {
220
290
  name: "rollback",
221
- description: "Make an earlier deploy live again (pick from history)"
291
+ description: "Make an earlier deploy live again, backend included (pick from history)"
222
292
  },
223
293
  args: {
224
294
  version: {
@@ -251,7 +321,8 @@ const rollbackCommand = defineCommand({
251
321
  }
252
322
  console.log(`Rolling back ${slug} to v${target}`);
253
323
  const { live: result } = await apiRequest("POST", `/projects/${slug}/deploys/${target}/rollback`);
254
- 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}`);
255
326
  })
256
327
  });
257
328
  const downloadCommand = defineCommand({
@@ -302,4 +373,4 @@ const downloadCommand = defineCommand({
302
373
  })
303
374
  });
304
375
  //#endregion
305
- 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,6 +1,6 @@
1
- import { a as projectDirArgs, c as resolveProject, i as projectDialect, l as tryReadCanopyJson, n as siteConfig } from "./site-DQnysZ3W.mjs";
2
- import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-BhzMeKQl.mjs";
3
- import { r as readMigrationFiles } from "./migrations-B0kJarjr.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";
4
4
  import { createRequire } from "node:module";
5
5
  import { defineCommand } from "citty";
6
6
  import { join } from "node:path";
@@ -87,13 +87,21 @@ async function devRemote(flags, slug) {
87
87
  const server = await startVite(flags, { proxy: { "/_api": {
88
88
  target: apiUrl,
89
89
  changeOrigin: true,
90
- headers: { authorization: `Bearer ${token}` },
90
+ headers: {
91
+ authorization: `Bearer ${token}`,
92
+ ...flags.staging ? { "x-canopy-channel": "staging" } : {}
93
+ },
91
94
  rewrite: rewriteApiPath
92
95
  } } });
93
- console.log(`\n ${slug} — remote-first dev against LIVE project data`);
94
- console.log(" (worried about a risky change? `canopy db snapshot` first, or work");
95
- console.log(" on a local copy instead: `canopy dev --local --pull`)");
96
- if (tryReadCanopyJson(flags.dir)?.migrations) {
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) {
97
105
  console.log(" migrations apply on `canopy deploy`, never from dev — use --local");
98
106
  console.log(" while the schema is in flux");
99
107
  }
@@ -104,9 +112,9 @@ async function devRemote(flags, slug) {
104
112
  async function devLocal(flags, slug) {
105
113
  let dump;
106
114
  if (flags.pull) {
107
- const { fetchProjectDump } = await import("./local-D9OVEHDg.mjs");
108
- console.log(`Pulling a fresh export of ${slug}…`);
109
- 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");
110
118
  }
111
119
  let remote;
112
120
  try {
@@ -119,7 +127,7 @@ async function devLocal(flags, slug) {
119
127
  const reason = err instanceof ApiError ? err.explain().why : String(err);
120
128
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
121
129
  }
122
- const { createSandbox } = await import("./local-D9OVEHDg.mjs");
130
+ const { createSandbox } = await import("./local-C47tmhl7.mjs");
123
131
  const sandbox = await createSandbox({
124
132
  slug,
125
133
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -212,18 +220,25 @@ const devCommand = defineCommand({
212
220
  type: "boolean",
213
221
  default: false,
214
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)"
215
228
  }
216
229
  },
217
230
  run: reportErrors("dev", async ({ args }) => {
218
231
  const port = args.port === void 0 ? void 0 : Number(args.port);
219
232
  if (port !== void 0 && !Number.isInteger(port)) throw new Error(`Invalid --port=${args.port}`);
220
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).");
221
235
  const { dir, slug } = resolveProject(args);
222
236
  const flags = {
223
237
  dir,
224
238
  port,
225
239
  local: args.local,
226
240
  pull: args.pull,
241
+ staging: args.staging,
227
242
  dialect: projectDialect(dir)
228
243
  };
229
244
  await (flags.local ? devLocal(flags, slug) : devRemote(flags, slug));
@@ -29,6 +29,19 @@ output directory (or the static files as-is for a `static` project):
29
29
  canopy deploy --no-build
30
30
  ```
31
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
+
32
45
  ## Rolling back
33
46
 
34
47
  Every deploy is kept, so you can instantly revert:
@@ -38,7 +51,8 @@ canopy rollback # pick a version from history
38
51
  canopy rollback v3 # make v3 live again
39
52
  ```
40
53
 
41
- 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.
42
56
 
43
57
  ## Downloading a version
44
58
 
@@ -146,6 +146,23 @@ export const db = drizzle(async (sql, params, method) => {
146
146
  (On a postgres site use `drizzle-orm/pg-proxy` the same way; in a GCP project
147
147
  worker, `env.DB.query(text, params)` fits `pg-proxy` directly.)
148
148
 
149
+ ## Rehearsing on staging
150
+
151
+ The safest way to try a migration is on the project's staging channel — a
152
+ private copy of the app whose database starts as a clone of live:
153
+
154
+ ```bash
155
+ canopy deploy --staging # pending migrations apply to the STAGING database
156
+ # … verify against the migrated copy …
157
+ canopy promote # the same migrations replay against live, then the version flips
158
+ ```
159
+
160
+ A migration that fails on staging costs nothing; at promote time the live
161
+ database gets its own pre-promote snapshot before anything applies.
162
+ `canopy db migrate --staging` (and `--plan`) target the staging database
163
+ directly, and `canopy staging sync` re-clones live when you want to rehearse
164
+ again on fresh data. See [Staging & promoting](https://docs.canopy.website/guides/staging).
165
+
149
166
  ## What deliberately doesn't work
150
167
 
151
168
  - **`drizzle-kit push` / `migrate` / `studio` / `introspect`** — these need a
@@ -0,0 +1,98 @@
1
+ ---
2
+ title: Staging & promoting
3
+ description: Deploy to a safe copy of your project first — own database cloned from live — then promote the exact same version.
4
+ ---
5
+
6
+ Every project can have a **staging channel**: a second, private copy of the
7
+ app with its own database (cloned from live), its own files, and its own
8
+ backend, served at
9
+
10
+ ```
11
+ https://staging-dot-<slug>.canopy.website
12
+ ```
13
+
14
+ behind the same sign-in as the live site. Deploy there first, click around
15
+ against real-shaped data, then **promote** — the exact version you tested
16
+ becomes live, nothing is rebuilt.
17
+
18
+ You don't set anything up. The first staged deploy creates the channel and
19
+ copies the live database into it.
20
+
21
+ ## The workflow
22
+
23
+ ```bash
24
+ canopy deploy --staging # build + deploy to staging (created on first use)
25
+ canopy open --staging # check it in the browser
26
+ canopy promote # happy? make that version live
27
+ ```
28
+
29
+ `canopy deploy --staging` is the normal deploy in every way — same build, same
30
+ versioning — except the result serves on the staging URL and the live site is
31
+ untouched. Versions share one sequence: if live is at v7 and you stage v8,
32
+ promoting makes that same v8 live.
33
+
34
+ `canopy promote` is not a redeploy: the platform re-uses the exact files and
35
+ backend bundle you staged. If migrations were rehearsed on staging, they replay
36
+ against the live database first (with an automatic snapshot), then the pointer
37
+ flips.
38
+
39
+ ## Testing schema migrations
40
+
41
+ This is the staging channel's superpower. Because the staging database starts
42
+ as a copy of live, a migration that works there will behave the same way on
43
+ the real thing:
44
+
45
+ ```bash
46
+ canopy deploy --staging # pending migrations apply to the STAGING database
47
+ # … verify the app against the migrated copy …
48
+ canopy promote # the same migrations replay against live, then v goes live
49
+ ```
50
+
51
+ A migration that fails on staging costs you nothing — live never saw it. See
52
+ [Schema migrations](https://docs.canopy.website/guides/migrations) for the migration system itself.
53
+
54
+ ## Keeping staging fresh
55
+
56
+ Staging data drifts as you test. Re-clone it from live anytime:
57
+
58
+ ```bash
59
+ canopy staging sync # replace staging data with a fresh copy of live
60
+ canopy staging status # what's staged vs live
61
+ ```
62
+
63
+ Writes on staging never flow back to live — promote moves code and schema, not
64
+ data.
65
+
66
+ ## What's shared, what's separate
67
+
68
+ Separate per channel: the database, uploaded files, and the backend worker.
69
+ Shared with live: members, secrets, the client password, schedules (they run
70
+ against live only), and the AI budget — staging traffic counts against the
71
+ same cap.
72
+
73
+ ## Developing against staging data
74
+
75
+ Remote dev can point at the staging database instead of live — useful while a
76
+ schema is in flux:
77
+
78
+ ```bash
79
+ canopy dev --staging
80
+ ```
81
+
82
+ ## Cleaning up
83
+
84
+ ```bash
85
+ canopy staging destroy # removes the staging database, files and backend
86
+ ```
87
+
88
+ Live is untouched, and the next `canopy deploy --staging` recreates the channel
89
+ from a fresh copy. The dashboard's Settings tab has the same controls, the
90
+ Overview tab shows what's staged with a Promote button, and a Live/Staging
91
+ toggle above the Data and Files tabs browses either channel's data.
92
+
93
+ ## Staging vs version previews
94
+
95
+ Version preview URLs (`v12-dot-<slug>`) show an old **frontend** against the
96
+ **live** data — good for comparing builds. The staging channel is the full
97
+ package: candidate frontend, candidate backend, and its own database. Use
98
+ previews to look backwards, staging to look forwards.
@@ -1,5 +1,5 @@
1
- import { n as siteConfig } from "./site-DQnysZ3W.mjs";
2
- import { n as reportErrors } from "./ui-BhzMeKQl.mjs";
1
+ import { n as siteConfig } from "./site-BUlQR117.mjs";
2
+ import { n as reportErrors } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-D1wDsLSU.mjs";
3
- import { r as siteHeaderLine, t as setInvocationProfile } from "./site-DQnysZ3W.mjs";
2
+ import { t as version } from "./package-CDB-uxy3.mjs";
3
+ import { r as siteHeaderLine, t as setInvocationProfile } from "./site-BUlQR117.mjs";
4
4
  import { i as runRefresh, r as notifyOfUpdate } from "./version-check-Cuh6HaLX.mjs";
5
5
  import { defineCommand, runMain } from "citty";
6
6
  //#region src/index.ts
@@ -73,24 +73,26 @@ const main = defineCommand({
73
73
  description: "canopy — create and deploy vibecoded projects"
74
74
  },
75
75
  subCommands: {
76
- login: () => import("./login-Cb8D8a1Z.mjs").then((m) => m.loginCommand),
77
- whoami: () => import("./login-Cb8D8a1Z.mjs").then((m) => m.whoamiCommand),
78
- logout: () => import("./login-Cb8D8a1Z.mjs").then((m) => m.logoutCommand),
79
- create: () => import("./projects-CoGh8m9A.mjs").then((m) => m.createCommand),
80
- init: () => import("./projects-CoGh8m9A.mjs").then((m) => m.initCommand),
81
- projects: () => import("./projects-CoGh8m9A.mjs").then((m) => m.projectsCommand),
82
- delete: () => import("./projects-CoGh8m9A.mjs").then((m) => m.deleteCommand),
83
- dev: () => import("./dev-DV5ud7AJ.mjs").then((m) => m.devCommand),
84
- deploy: () => import("./deploy-DdSSL227.mjs").then((m) => m.deployCommand),
85
- rollback: () => import("./deploy-DdSSL227.mjs").then((m) => m.rollbackCommand),
86
- download: () => import("./deploy-DdSSL227.mjs").then((m) => m.downloadCommand),
87
- token: () => import("./token-CWgp_Lqe.mjs").then((m) => m.tokenCommand),
88
- db: () => import("./db-BpnjQUkF.mjs").then((m) => m.dbCommand),
89
- usage: () => import("./usage-CYQRLXwf.mjs").then((m) => m.usageCommand),
90
- open: () => import("./open-BmgYBSdR.mjs").then((m) => m.openCommand),
91
- docs: () => import("./docs-Cept8t_3.mjs").then((m) => m.docsCommand),
92
- skill: () => import("./skill-192EdixC.mjs").then((m) => m.skillCommand),
93
- upgrade: () => import("./upgrade-BrHSSg2e.mjs").then((m) => m.upgradeCommand),
76
+ login: () => import("./login-ByD0hcxu.mjs").then((m) => m.loginCommand),
77
+ whoami: () => import("./login-ByD0hcxu.mjs").then((m) => m.whoamiCommand),
78
+ logout: () => import("./login-ByD0hcxu.mjs").then((m) => m.logoutCommand),
79
+ create: () => import("./projects-CSH7KsPi.mjs").then((m) => m.createCommand),
80
+ init: () => import("./projects-CSH7KsPi.mjs").then((m) => m.initCommand),
81
+ projects: () => import("./projects-CSH7KsPi.mjs").then((m) => m.projectsCommand),
82
+ delete: () => import("./projects-CSH7KsPi.mjs").then((m) => m.deleteCommand),
83
+ dev: () => import("./dev-CvtoGFW0.mjs").then((m) => m.devCommand),
84
+ deploy: () => import("./deploy-BcczXlSQ.mjs").then((m) => m.deployCommand),
85
+ promote: () => import("./deploy-BcczXlSQ.mjs").then((m) => m.promoteCommand),
86
+ staging: () => import("./staging-CfF2-pHs.mjs").then((m) => m.stagingCommand),
87
+ rollback: () => import("./deploy-BcczXlSQ.mjs").then((m) => m.rollbackCommand),
88
+ download: () => import("./deploy-BcczXlSQ.mjs").then((m) => m.downloadCommand),
89
+ token: () => import("./token-nieJV1w7.mjs").then((m) => m.tokenCommand),
90
+ db: () => import("./db-BkUmbGgx.mjs").then((m) => m.dbCommand),
91
+ usage: () => import("./usage-qqPtkDtT.mjs").then((m) => m.usageCommand),
92
+ open: () => import("./open-DD9oa-Ho.mjs").then((m) => m.openCommand),
93
+ docs: () => import("./docs-2cUEug69.mjs").then((m) => m.docsCommand),
94
+ skill: () => import("./skill-DrWzmUMv.mjs").then((m) => m.skillCommand),
95
+ upgrade: () => import("./upgrade-CH_jHkc7.mjs").then((m) => m.upgradeCommand),
94
96
  logs: planned("logs", "Tail worker logs", "2")
95
97
  }
96
98
  });
@@ -1,5 +1,5 @@
1
- import { o as apiDataRequest } from "./ui-BhzMeKQl.mjs";
2
- import { n as rewriteApiPath } from "./dev-OxeYndd1.mjs";
1
+ import { o as apiDataRequest } from "./ui-D8HxX0Ng.mjs";
2
+ import { n as rewriteApiPath } from "./dev-DvyKHe2d.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, relative, sep } from "node:path";
@@ -1002,8 +1002,8 @@ function resolveSdkBundle() {
1002
1002
  return bundle;
1003
1003
  }
1004
1004
  /** Downloads a fresh SQL dump of the project's production database (pull-only sync). */
1005
- async function fetchProjectDump(slug) {
1006
- const { url } = await apiDataRequest("POST", "/db/export", slug);
1005
+ async function fetchProjectDump(slug, channel = "live") {
1006
+ const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channel);
1007
1007
  const download = await fetch(url);
1008
1008
  if (!download.ok) throw new Error(`Dump download failed (HTTP ${download.status}).`);
1009
1009
  return download.text();
@@ -1,5 +1,5 @@
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";
1
+ import { _ as getBakedProfile, d as clearAllProfileTokens, f as listBakedProfiles, g as writeConfigFile, h as upsertProfileAuth, m as setActiveProfile, n as siteConfig, p as readConfigFile, v as isProfileId } from "./site-BUlQR117.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { createHash, randomBytes } from "node:crypto";
@@ -1,5 +1,5 @@
1
- import { l as tryReadCanopyJson } from "./site-DQnysZ3W.mjs";
2
- import { o as apiDataRequest } from "./ui-BhzMeKQl.mjs";
1
+ import { u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
+ import { o as apiDataRequest } from "./ui-D8HxX0Ng.mjs";
3
3
  import { readFileSync, readdirSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  //#region src/migrations.ts
@@ -30,12 +30,12 @@ function readMigrationFiles(dir) {
30
30
  sql: readFileSync(join(migrationsDir, entry.name), "utf8")
31
31
  })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
32
32
  }
33
- /** Run (or dry-run) the project's migrations against its live database. */
34
- function migrateProject(slug, migrations, plan = false) {
33
+ /** Run (or dry-run) the project's migrations against a channel's database. */
34
+ function migrateProject(slug, migrations, plan = false, channel = "live") {
35
35
  return apiDataRequest("POST", "/db/migrate", slug, {
36
36
  migrations,
37
37
  plan
38
- });
38
+ }, void 0, channel);
39
39
  }
40
40
  /** One-line-per-fact summary shared by `db migrate` and the deploy step. */
41
41
  function describeMigrateResult(result) {
@@ -1,5 +1,5 @@
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";
1
+ import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-BUlQR117.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/open.ts
5
5
  /**
@@ -11,10 +11,17 @@ const openCommand = defineCommand({
11
11
  name: "open",
12
12
  description: "Open the deployed project in your browser"
13
13
  },
14
- args: projectDirArgs,
14
+ args: {
15
+ ...projectDirArgs,
16
+ staging: {
17
+ type: "boolean",
18
+ default: false,
19
+ description: "Open the staging channel instead of live"
20
+ }
21
+ },
15
22
  run: reportErrors("open", async ({ args }) => {
16
23
  const { slug } = resolveProject(args);
17
- const url = projectUrl(slug);
24
+ const url = args.staging ? stagingUrl(slug) : projectUrl(slug);
18
25
  console.log(`Opening ${url}`);
19
26
  openBrowser(url);
20
27
  })
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.6.0";
2
+ var version = "0.7.0";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,5 +1,5 @@
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";
1
+ import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
+ import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-D8HxX0Ng.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";
@@ -175,6 +175,12 @@ function projectUrl(slug) {
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(".")}`;
177
177
  }
178
+ /** The staging channel's URL (docs/14): the first label becomes `staging-dot-<slug>`. */
179
+ function stagingUrl(slug) {
180
+ const url = new URL(projectUrl(slug));
181
+ url.hostname = `staging-dot-${url.hostname}`;
182
+ return url.origin;
183
+ }
178
184
  /** canopy.json parsed if present; undefined when the file is missing. */
179
185
  function tryReadCanopyJson(dir) {
180
186
  let raw;
@@ -258,4 +264,4 @@ function siteHeaderLine(cliVersion) {
258
264
  return `canopy ${cliVersion} · ${c.envOverride || !c.profileId ? c.apiUrl : `${c.profileId} (${c.projectDomain ?? new URL(c.apiUrl).hostname})`}`;
259
265
  }
260
266
  //#endregion
261
- export { isProfileId as _, projectDirArgs as a, resolveProject as c, listBakedProfiles as d, readConfigFile as f, getBakedProfile as g, writeConfigFile as h, projectDialect as i, tryReadCanopyJson as l, upsertProfileAuth as m, siteConfig as n, projectFlagArgs as o, setActiveProfile as p, siteHeaderLine as r, projectUrl as s, setInvocationProfile as t, clearAllProfileTokens as u };
267
+ export { getBakedProfile as _, projectDirArgs as a, resolveProject as c, clearAllProfileTokens as d, listBakedProfiles as f, writeConfigFile as g, upsertProfileAuth as h, projectDialect as i, stagingUrl as l, setActiveProfile as m, siteConfig as n, projectFlagArgs as o, readConfigFile as p, siteHeaderLine as r, projectUrl as s, setInvocationProfile as t, tryReadCanopyJson as u, isProfileId as v };
@@ -89,6 +89,8 @@ truth and never go stale.
89
89
  current directory; add `--no-scaffold` to link an app you already have.
90
90
  - Run locally: `canopy dev` (live data) or `canopy dev --local` (offline sandbox)
91
91
  - Deploy: `canopy deploy` (add `--build` if the project has a build step)
92
+ - Test first on staging: `canopy deploy --staging` → verify → `canopy promote`
93
+ (own database cloned from live; `canopy staging status | sync | destroy`)
92
94
  - Roll back: `canopy rollback`
93
95
  - Open the live site: `canopy open`
94
96
  - Manage data: `canopy db pull | snapshot | snapshots | restore | migrate`
@@ -110,6 +112,10 @@ confusing failures:
110
112
  deploy; `canopy dev --local` applies to the sandbox on boot).
111
113
  - Never edit an applied migration (the platform refuses edited history) and
112
114
  never reference `_canopy_*` tables.
115
+ - **Schema change? Rehearse it on staging.** `canopy deploy --staging` applies
116
+ pending migrations to the staging database (a clone of live) — verify the
117
+ app there, then `canopy promote` replays them against live and flips the
118
+ version. Never promote with failing checks.
113
119
 
114
120
  ## Choosing a starter template (`canopy create` / `canopy init`)
115
121
 
@@ -1,5 +1,5 @@
1
- import { t as version } from "./package-D1wDsLSU.mjs";
2
- import { n as reportErrors } from "./ui-BhzMeKQl.mjs";
1
+ import { t as version } from "./package-CDB-uxy3.mjs";
2
+ import { n as reportErrors } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { join, relative } from "node:path";
@@ -0,0 +1,113 @@
1
+ import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-BUlQR117.mjs";
2
+ import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, r as requireTty } from "./ui-D8HxX0Ng.mjs";
3
+ import { defineCommand } from "citty";
4
+ import * as p from "@clack/prompts";
5
+ //#region src/staging.ts
6
+ /**
7
+ * canopy staging status | sync | destroy — the project's staging channel
8
+ * (docs/14): its own database (a clone of live), blob prefix and backend
9
+ * worker, served at staging-dot-<slug>.<domain>. Created lazily by
10
+ * `canopy deploy --staging`; promoted with `canopy promote`.
11
+ */
12
+ const fetchStatus = (slug) => apiRequest("GET", `/projects/${slug}/staging`);
13
+ const stagingCommand = defineCommand({
14
+ meta: {
15
+ name: "staging",
16
+ description: "Staging channel: status, sync, destroy"
17
+ },
18
+ subCommands: {
19
+ status: defineCommand({
20
+ meta: {
21
+ name: "status",
22
+ description: "Show the staging channel's state"
23
+ },
24
+ args: projectFlagArgs,
25
+ run: reportErrors("staging status", async ({ args }) => {
26
+ const { slug } = resolveProject(args);
27
+ const { staging } = await fetchStatus(slug);
28
+ if (!staging.provisioned) {
29
+ console.log(`No staging channel for ${slug} — create one with \`canopy deploy --staging\`.`);
30
+ return;
31
+ }
32
+ console.log(`Staging: ${stagingUrl(slug)}`);
33
+ console.log(` staged v${staging.version ?? "— (nothing deployed yet)"}` + (staging.hasWorker ? " (with backend)" : ""));
34
+ console.log(` live v${staging.live ?? "—"} ${projectUrl(slug)}`);
35
+ if (staging.version !== null && staging.version !== staging.live) console.log(` \`canopy promote\` makes v${staging.version} live`);
36
+ })
37
+ }),
38
+ sync: defineCommand({
39
+ meta: {
40
+ name: "sync",
41
+ description: "Re-clone the live database into staging (staging data is replaced)"
42
+ },
43
+ args: {
44
+ ...projectFlagArgs,
45
+ yes: {
46
+ type: "boolean",
47
+ default: false,
48
+ description: "Skip the confirmation prompt"
49
+ }
50
+ },
51
+ run: reportErrors("staging sync", async ({ args }) => {
52
+ const { slug } = resolveProject(args);
53
+ if (!args.yes) {
54
+ requireTty("pass --yes to sync without confirmation.");
55
+ if (!unwrap(await p.confirm({ message: `Replace "${slug}"'s STAGING data with a fresh copy of live?` }))) {
56
+ p.cancel("Aborted.");
57
+ return;
58
+ }
59
+ }
60
+ const spin = p.spinner();
61
+ spin.start(`Cloning live → staging for ${slug}…`);
62
+ try {
63
+ await withContext(apiRequest("POST", `/projects/${slug}/staging/sync`), {
64
+ action: `sync staging for "${slug}"`,
65
+ fixes: { staging_not_provisioned: "This project has no staging channel yet — `canopy deploy --staging` creates one (already cloned from live)." }
66
+ });
67
+ } catch (err) {
68
+ spin.error("Sync failed.");
69
+ throw err;
70
+ }
71
+ spin.stop(`Staging database now matches live for ${slug}.`);
72
+ })
73
+ }),
74
+ destroy: defineCommand({
75
+ meta: {
76
+ name: "destroy",
77
+ description: "Tear the staging channel down (database, files, backend — live is untouched)"
78
+ },
79
+ args: {
80
+ ...projectFlagArgs,
81
+ yes: {
82
+ type: "boolean",
83
+ default: false,
84
+ description: "Skip the type-the-slug confirmation"
85
+ }
86
+ },
87
+ run: reportErrors("staging destroy", async ({ args }) => {
88
+ const { slug } = resolveProject(args);
89
+ if (!args.yes) {
90
+ requireTty("pass --yes to destroy without confirmation.");
91
+ if (unwrap(await p.text({ message: `This deletes "${slug}"'s staging database, files and backend (live is untouched). Type the slug to confirm.` })).trim() !== slug) {
92
+ p.cancel("Aborted.");
93
+ return;
94
+ }
95
+ }
96
+ const spin = p.spinner();
97
+ spin.start(`Destroying staging for ${slug}…`);
98
+ try {
99
+ await withContext(apiRequest("DELETE", `/projects/${slug}/staging`), {
100
+ action: `destroy staging for "${slug}"`,
101
+ fixes: { staging_not_provisioned: "This project has no staging channel." }
102
+ });
103
+ } catch (err) {
104
+ spin.error("Destroy failed.");
105
+ throw err;
106
+ }
107
+ spin.stop(`Staging channel removed for ${slug}. \`canopy deploy --staging\` recreates it.`);
108
+ })
109
+ })
110
+ }
111
+ });
112
+ //#endregion
113
+ export { stagingCommand };
@@ -30,6 +30,10 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
30
30
  writes never reach the platform. Prefer it for destructive experiments.
31
31
  - `canopy deploy` — builds (`npm run build`) and publishes `dist/`; live
32
32
  (behind company login) at https://{{slug}}.thanarie.dev.
33
+ - Risky change (schema migration, backend rework)? `canopy deploy --staging`
34
+ first — a private copy with its own database cloned from live — verify, then
35
+ `canopy promote` ships the exact tested version. Never promote with failing
36
+ checks.
33
37
 
34
38
  ## Boundaries
35
39
 
@@ -29,6 +29,10 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
29
29
  writes never reach the platform. Prefer it for destructive experiments.
30
30
  - `canopy deploy` — publish; live (behind company login) at
31
31
  https://{{slug}}.thanarie.dev.
32
+ - Risky change (schema migration, backend rework)? `canopy deploy --staging`
33
+ first — a private copy with its own database cloned from live — verify, then
34
+ `canopy promote` ships the exact tested version. Never promote with failing
35
+ checks.
32
36
 
33
37
  ## Boundaries
34
38
 
@@ -42,6 +42,10 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
42
42
  - Defining your own tables (raw `sql()` / Drizzle)? Use the migrations flow —
43
43
  `canopy docs migrations`. Never invent a `DATABASE_URL` (none exists):
44
44
  generate migrations locally, and `canopy deploy` applies them.
45
+ - Risky change (schema migration, backend rework)? `canopy deploy --staging`
46
+ first — a private copy with its own database cloned from live — verify, then
47
+ `canopy promote` ships the exact tested version. Never promote with failing
48
+ checks.
45
49
 
46
50
  ## Boundaries
47
51
 
@@ -1,5 +1,5 @@
1
- import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-DQnysZ3W.mjs";
2
- import { c as apiRequest, n as reportErrors } from "./ui-BhzMeKQl.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
2
+ import { c as apiRequest, n as reportErrors } from "./ui-D8HxX0Ng.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  const tokenCommand = defineCommand({
5
5
  meta: {
@@ -1,4 +1,4 @@
1
- import { n as siteConfig } from "./site-DQnysZ3W.mjs";
1
+ import { n as siteConfig } from "./site-BUlQR117.mjs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { cancel, isCancel } from "@clack/prompts";
4
4
  //#region src/api.ts
@@ -211,13 +211,14 @@ async function apiDownload(path) {
211
211
  * unscoped; the target project rides as X-Canopy-Project and the api. gateway
212
212
  * re-vouches it (docs/10-cli-sites.md D17).
213
213
  */
214
- async function apiDataRequest(method, path, projectSlug, body, rawContentType) {
214
+ async function apiDataRequest(method, path, projectSlug, body, rawContentType, channel) {
215
215
  const { apiUrl, token, isDeployToken } = credentials();
216
216
  const response = await fetch(`${apiUrl}/data/v1${path}`, {
217
217
  method,
218
218
  headers: {
219
219
  authorization: `Bearer ${token}`,
220
220
  "x-canopy-project": projectSlug,
221
+ ...channel === "staging" ? { "x-canopy-channel": "staging" } : {},
221
222
  ...body === void 0 ? {} : { "content-type": rawContentType ?? "application/json" }
222
223
  },
223
224
  body: body === void 0 ? void 0 : rawContentType !== void 0 ? body : JSON.stringify(body)
@@ -1,5 +1,5 @@
1
1
  import { t as PACKAGE_NAME } from "./version-check-Cuh6HaLX.mjs";
2
- import { n as reportErrors } from "./ui-BhzMeKQl.mjs";
2
+ import { n as reportErrors } from "./ui-D8HxX0Ng.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-192EdixC.mjs");
115
+ const { skillInstalled } = await import("./skill-DrWzmUMv.mjs");
116
116
  const refreshSkill = skillInstalled(process.cwd());
117
117
  console.log(`Upgrading via ${command}: ${command} ${cmd.join(" ")}`);
118
118
  await run(command, cmd);
@@ -1,5 +1,5 @@
1
- import { c as resolveProject, o as projectFlagArgs } from "./site-DQnysZ3W.mjs";
2
- import { c as apiRequest, l as withContext, n as reportErrors } from "./ui-BhzMeKQl.mjs";
1
+ import { c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
2
+ import { c as apiRequest, l as withContext, n as reportErrors } from "./ui-D8HxX0Ng.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.6.0",
3
+ "version": "0.7.0",
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-data-plane": "0.0.2",
24
- "@mediamonks/canopy": "0.0.3"
23
+ "@mediamonks/canopy": "0.0.3",
24
+ "@mediamonks/canopy-data-plane": "0.0.2"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.1",
@@ -1,2 +0,0 @@
1
- import { t as devCommand } from "./dev-OxeYndd1.mjs";
2
- export { devCommand };