@mediamonks/canopy-cli 0.6.0 → 0.7.1
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-BpnjQUkF.mjs → db-BkUmbGgx.mjs} +35 -18
- package/dist/{deploy-DdSSL227.mjs → deploy-BcczXlSQ.mjs} +83 -12
- package/dist/dev-CvtoGFW0.mjs +2 -0
- package/dist/{dev-OxeYndd1.mjs → dev-DvyKHe2d.mjs} +27 -12
- package/dist/docs/deploy.md +15 -1
- package/dist/docs/migrations.md +17 -0
- package/dist/docs/staging.md +106 -0
- package/dist/{docs-Cept8t_3.mjs → docs-2cUEug69.mjs} +2 -2
- package/dist/index.mjs +22 -20
- package/dist/{local-D9OVEHDg.mjs → local-C47tmhl7.mjs} +4 -4
- package/dist/{login-Cb8D8a1Z.mjs → login-ByD0hcxu.mjs} +2 -2
- package/dist/{migrations-B0kJarjr.mjs → migrations-I1adj0z2.mjs} +5 -5
- package/dist/{open-BmgYBSdR.mjs → open-DD9oa-Ho.mjs} +11 -4
- package/dist/{package-D1wDsLSU.mjs → package-WhBa4V15.mjs} +1 -1
- package/dist/{projects-CoGh8m9A.mjs → projects-CSH7KsPi.mjs} +2 -2
- package/dist/{site-DQnysZ3W.mjs → site-BUlQR117.mjs} +7 -1
- package/dist/skill/canopy/SKILL.md +6 -0
- package/dist/{skill-192EdixC.mjs → skill-Di9GQYYx.mjs} +2 -2
- package/dist/staging-9bvgl2B5.mjs +155 -0
- package/dist/templates/spa/AGENTS.md +4 -0
- package/dist/templates/static/AGENTS.md +4 -0
- package/dist/templates/worker/AGENTS.md +4 -0
- package/dist/{token-CWgp_Lqe.mjs → token-nieJV1w7.mjs} +2 -2
- package/dist/{ui-BhzMeKQl.mjs → ui-D8HxX0Ng.mjs} +3 -2
- package/dist/{upgrade-BrHSSg2e.mjs → upgrade-CZwo05Tz.mjs} +2 -2
- package/dist/{usage-CYQRLXwf.mjs → usage-qqPtkDtT.mjs} +2 -2
- package/package.json +3 -3
- package/dist/dev-DV5ud7AJ.mjs +0 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as resolveProject, o as projectFlagArgs } from "./site-
|
|
2
|
-
import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
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:
|
|
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:
|
|
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
|
|
2
|
-
import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, s as apiDownload } from "./ui-
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
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
|
-
|
|
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`, {
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, i as projectDialect,
|
|
2
|
-
import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-
|
|
3
|
-
import { r as readMigrationFiles } from "./migrations-
|
|
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: {
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: `Bearer ${token}`,
|
|
92
|
+
...flags.staging ? { "x-canopy-channel": "staging" } : {}
|
|
93
|
+
},
|
|
91
94
|
rewrite: rewriteApiPath
|
|
92
95
|
} } });
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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-
|
|
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-
|
|
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));
|
package/dist/docs/deploy.md
CHANGED
|
@@ -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
|
|
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
|
|
package/dist/docs/migrations.md
CHANGED
|
@@ -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,106 @@
|
|
|
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
|
+
## Staging an older version
|
|
55
|
+
|
|
56
|
+
Any version in the deploy history can be pointed at the staging channel —
|
|
57
|
+
from the dashboard's deploy list (the **Stage** action, next to Rollback) or
|
|
58
|
+
with `canopy staging use v3`. Like rollback, it's a pointer move: nothing is
|
|
59
|
+
rebuilt, and live is untouched. Handy for comparing an old build against the
|
|
60
|
+
staging data, or re-testing a version before promoting it.
|
|
61
|
+
|
|
62
|
+
## Keeping staging fresh
|
|
63
|
+
|
|
64
|
+
Staging data drifts as you test. Re-clone it from live anytime:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
canopy staging sync # replace staging data with a fresh copy of live
|
|
68
|
+
canopy staging status # what's staged vs live
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Writes on staging never flow back to live — promote moves code and schema, not
|
|
72
|
+
data.
|
|
73
|
+
|
|
74
|
+
## What's shared, what's separate
|
|
75
|
+
|
|
76
|
+
Separate per channel: the database, uploaded files, and the backend worker.
|
|
77
|
+
Shared with live: members, secrets, the client password, schedules (they run
|
|
78
|
+
against live only), and the AI budget — staging traffic counts against the
|
|
79
|
+
same cap.
|
|
80
|
+
|
|
81
|
+
## Developing against staging data
|
|
82
|
+
|
|
83
|
+
Remote dev can point at the staging database instead of live — useful while a
|
|
84
|
+
schema is in flux:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
canopy dev --staging
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Cleaning up
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
canopy staging destroy # removes the staging database, files and backend
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Live is untouched, and the next `canopy deploy --staging` recreates the channel
|
|
97
|
+
from a fresh copy. The dashboard's Settings tab has the same controls, the
|
|
98
|
+
Overview tab shows what's staged with a Promote button, and a Live/Staging
|
|
99
|
+
toggle above the Data and Files tabs browses either channel's data.
|
|
100
|
+
|
|
101
|
+
## Staging vs version previews
|
|
102
|
+
|
|
103
|
+
Version preview URLs (`v12-dot-<slug>`) show an old **frontend** against the
|
|
104
|
+
**live** data — good for comparing builds. The staging channel is the full
|
|
105
|
+
package: candidate frontend, candidate backend, and its own database. Use
|
|
106
|
+
previews to look backwards, staging to look forwards.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as siteConfig } from "./site-
|
|
2
|
-
import { n as reportErrors } from "./ui-
|
|
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-
|
|
3
|
-
import { r as siteHeaderLine, t as setInvocationProfile } from "./site-
|
|
2
|
+
import { t as version } from "./package-WhBa4V15.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-
|
|
77
|
-
whoami: () => import("./login-
|
|
78
|
-
logout: () => import("./login-
|
|
79
|
-
create: () => import("./projects-
|
|
80
|
-
init: () => import("./projects-
|
|
81
|
-
projects: () => import("./projects-
|
|
82
|
-
delete: () => import("./projects-
|
|
83
|
-
dev: () => import("./dev-
|
|
84
|
-
deploy: () => import("./deploy-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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-9bvgl2B5.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-Di9GQYYx.mjs").then((m) => m.skillCommand),
|
|
95
|
+
upgrade: () => import("./upgrade-CZwo05Tz.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-
|
|
2
|
-
import { n as rewriteApiPath } from "./dev-
|
|
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
|
|
2
|
-
import { n as reportErrors, t as openBrowser } from "./ui-
|
|
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 {
|
|
2
|
-
import { o as apiDataRequest } from "./ui-
|
|
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
|
|
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-
|
|
2
|
-
import { n as reportErrors, t as openBrowser } from "./ui-
|
|
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:
|
|
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,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-
|
|
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 {
|
|
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-
|
|
2
|
-
import { n as reportErrors } from "./ui-
|
|
1
|
+
import { t as version } from "./package-WhBa4V15.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,155 @@
|
|
|
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, use, 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
|
+
use: defineCommand({
|
|
39
|
+
meta: {
|
|
40
|
+
name: "use",
|
|
41
|
+
description: "Serve an existing version on the staging channel (pick from history)"
|
|
42
|
+
},
|
|
43
|
+
args: {
|
|
44
|
+
version: {
|
|
45
|
+
type: "positional",
|
|
46
|
+
description: "Version to stage, e.g. v3 (picked from a list when omitted)",
|
|
47
|
+
required: false
|
|
48
|
+
},
|
|
49
|
+
...projectFlagArgs
|
|
50
|
+
},
|
|
51
|
+
run: reportErrors("staging use", async ({ args }) => {
|
|
52
|
+
const { slug } = resolveProject(args);
|
|
53
|
+
let target;
|
|
54
|
+
if (args.version !== void 0) {
|
|
55
|
+
if (!/^v?\d+$/.test(args.version)) throw new Error(`Invalid version "${args.version}" — expected a number like 3 or v3.`);
|
|
56
|
+
target = Number(args.version.replace(/^v/, ""));
|
|
57
|
+
} else {
|
|
58
|
+
requireTty("pass a version, e.g. `canopy staging use v3`.");
|
|
59
|
+
const { deploys, live, staging: staged } = await apiRequest("GET", `/projects/${slug}/deploys`);
|
|
60
|
+
if (deploys.length === 0) throw new Error("This project has no deploys yet.");
|
|
61
|
+
target = unwrap(await p.select({
|
|
62
|
+
message: "Version to serve on staging",
|
|
63
|
+
options: deploys.map((deploy) => ({
|
|
64
|
+
value: deploy.version,
|
|
65
|
+
label: `v${deploy.version} ${deploy.created_at} (${deploy.source})`,
|
|
66
|
+
hint: deploy.version === staged ? "staged" : deploy.version === live ? "live" : void 0
|
|
67
|
+
}))
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
const { staging: result } = await withContext(apiRequest("POST", `/projects/${slug}/deploys/${target}/stage`), {
|
|
71
|
+
action: `stage "${slug}" v${target}`,
|
|
72
|
+
fixes: {
|
|
73
|
+
staging_not_provisioned: "This project has no staging channel yet — create one in the dashboard's Settings, or with `canopy deploy --staging`.",
|
|
74
|
+
already_staged: `v${target} is already the staged version.`
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
console.log(`Staging: ${stagingUrl(slug)} (v${result.version}, was v${result.previous ?? "—"})`);
|
|
78
|
+
})
|
|
79
|
+
}),
|
|
80
|
+
sync: defineCommand({
|
|
81
|
+
meta: {
|
|
82
|
+
name: "sync",
|
|
83
|
+
description: "Re-clone the live database into staging (staging data is replaced)"
|
|
84
|
+
},
|
|
85
|
+
args: {
|
|
86
|
+
...projectFlagArgs,
|
|
87
|
+
yes: {
|
|
88
|
+
type: "boolean",
|
|
89
|
+
default: false,
|
|
90
|
+
description: "Skip the confirmation prompt"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
run: reportErrors("staging sync", async ({ args }) => {
|
|
94
|
+
const { slug } = resolveProject(args);
|
|
95
|
+
if (!args.yes) {
|
|
96
|
+
requireTty("pass --yes to sync without confirmation.");
|
|
97
|
+
if (!unwrap(await p.confirm({ message: `Replace "${slug}"'s STAGING data with a fresh copy of live?` }))) {
|
|
98
|
+
p.cancel("Aborted.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const spin = p.spinner();
|
|
103
|
+
spin.start(`Cloning live → staging for ${slug}…`);
|
|
104
|
+
try {
|
|
105
|
+
await withContext(apiRequest("POST", `/projects/${slug}/staging/sync`), {
|
|
106
|
+
action: `sync staging for "${slug}"`,
|
|
107
|
+
fixes: { staging_not_provisioned: "This project has no staging channel yet — `canopy deploy --staging` creates one (already cloned from live)." }
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
spin.error("Sync failed.");
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
spin.stop(`Staging database now matches live for ${slug}.`);
|
|
114
|
+
})
|
|
115
|
+
}),
|
|
116
|
+
destroy: defineCommand({
|
|
117
|
+
meta: {
|
|
118
|
+
name: "destroy",
|
|
119
|
+
description: "Tear the staging channel down (database, files, backend — live is untouched)"
|
|
120
|
+
},
|
|
121
|
+
args: {
|
|
122
|
+
...projectFlagArgs,
|
|
123
|
+
yes: {
|
|
124
|
+
type: "boolean",
|
|
125
|
+
default: false,
|
|
126
|
+
description: "Skip the type-the-slug confirmation"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
run: reportErrors("staging destroy", async ({ args }) => {
|
|
130
|
+
const { slug } = resolveProject(args);
|
|
131
|
+
if (!args.yes) {
|
|
132
|
+
requireTty("pass --yes to destroy without confirmation.");
|
|
133
|
+
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) {
|
|
134
|
+
p.cancel("Aborted.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const spin = p.spinner();
|
|
139
|
+
spin.start(`Destroying staging for ${slug}…`);
|
|
140
|
+
try {
|
|
141
|
+
await withContext(apiRequest("DELETE", `/projects/${slug}/staging`), {
|
|
142
|
+
action: `destroy staging for "${slug}"`,
|
|
143
|
+
fixes: { staging_not_provisioned: "This project has no staging channel." }
|
|
144
|
+
});
|
|
145
|
+
} catch (err) {
|
|
146
|
+
spin.error("Destroy failed.");
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
spin.stop(`Staging channel removed for ${slug}. \`canopy deploy --staging\` recreates it.`);
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
//#endregion
|
|
155
|
+
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-
|
|
2
|
-
import { c as apiRequest, n as reportErrors } from "./ui-
|
|
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-
|
|
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-
|
|
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-
|
|
115
|
+
const { skillInstalled } = await import("./skill-Di9GQYYx.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-
|
|
2
|
-
import { c as apiRequest, l as withContext, n as reportErrors } from "./ui-
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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.3",
|
|
24
|
+
"@mediamonks/canopy-data-plane": "0.0.2"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.1",
|
package/dist/dev-DV5ud7AJ.mjs
DELETED