@mediamonks/canopy-cli 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/{db-PpaU3V-B.mjs → db-BW1ZolWn.mjs} +8 -11
- package/dist/{deploy-B064KXtr.mjs → deploy-alC2--pZ.mjs} +92 -12
- package/dist/dev-BJ5xEwqW.mjs +2 -0
- package/dist/{dev-CuvlJpcO.mjs → dev-CQQhMOoD.mjs} +22 -24
- package/dist/docs/ci.md +6 -2
- package/dist/docs/cli.md +8 -3
- package/dist/docs/data.md +22 -4
- package/dist/docs/deploy.md +10 -6
- package/dist/docs/dev.md +10 -3
- package/dist/docs/infrastructure.md +397 -0
- package/dist/docs/static-serving.md +318 -0
- package/dist/docs/troubleshooting.md +14 -0
- package/dist/{docs-UYzmzpz-.mjs → docs-DvSPpSIR.mjs} +8 -7
- package/dist/index.mjs +53 -19
- package/dist/local-C5-GWJcx.mjs +826 -0
- package/dist/login-Bz-rwlFF.mjs +264 -0
- package/dist/{open-CBuZBJlv.mjs → open-DnlYDqrU.mjs} +2 -2
- package/dist/{package-Bnu23N0A.mjs → package-Bi5FfVSP.mjs} +1 -1
- package/dist/{projects-CK4DNxgZ.mjs → projects-ijfe7Ffx.mjs} +21 -3
- package/dist/site-BJjoePQS.mjs +254 -0
- package/dist/skill/canopy/SKILL.md +1 -1
- package/dist/{skill-h7QlCMhc.mjs → skill-IAAa2EkK.mjs} +2 -2
- package/dist/templates/static/AGENTS.md +1 -1
- package/dist/templates/static/main.js +1 -1
- package/dist/templates/worker/AGENTS.md +11 -9
- package/dist/templates/worker/worker/index.ts +27 -4
- package/dist/{token-yuwQTQB8.mjs → token-BeF9zczg.mjs} +2 -2
- package/dist/{ui-D1DXoDN1.mjs → ui-DThlyHgo.mjs} +28 -36
- package/dist/{upgrade-sXoqYLRu.mjs → upgrade-DNxR-zh7.mjs} +3 -3
- package/dist/usage-DQpH7A3X.mjs +79 -0
- package/package.json +4 -3
- package/dist/dev-BYv6BmcI.mjs +0 -2
- package/dist/local-B3dyT2fh.mjs +0 -563
- package/dist/login-DdNc9OjU.mjs +0 -173
- package/dist/project-DKbgJACM.mjs +0 -54
- package/dist/{version-check-CoWgQs2h.mjs → version-check-Cuh6HaLX.mjs} +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @mediamonks/canopy-cli
|
|
2
|
+
|
|
3
|
+
`canopy` — create, develop, deploy and manage Canopy projects from the
|
|
4
|
+
terminal.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
canopy login # OneLogin browser PKCE flow → CLI token
|
|
8
|
+
canopy create / projects / delete
|
|
9
|
+
canopy dev [--local [--pull]] # remote-first dev, or the offline sandbox
|
|
10
|
+
canopy deploy [--no-build] # authorize → signed upload → complete
|
|
11
|
+
canopy rollback / deploys / download
|
|
12
|
+
canopy db pull | snapshot | snapshots | restore
|
|
13
|
+
canopy token create | list | revoke
|
|
14
|
+
canopy usage / docs / whoami / logout
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Control-plane calls go through `src/api.ts` (`/control/v1` on the active
|
|
18
|
+
site's `api.` host, bearer token — `CANOPY_DEPLOY_TOKEN` in CI wins over the
|
|
19
|
+
login token); database safety runs on the data plane (`apiDataRequest` with
|
|
20
|
+
`X-Canopy-Project`). `canopy deploy` streams bundle bytes straight to
|
|
21
|
+
`/data/v1/uploads/:grant` under a short-lived signed grant — they never
|
|
22
|
+
transit the control plane (docs/09-plane-split.md D-d). Multi-site profiles:
|
|
23
|
+
docs/10-cli-sites.md.
|
|
24
|
+
|
|
25
|
+
`canopy dev` proxies `/_api` to the platform with a 12-hour project-scoped
|
|
26
|
+
dev token; `--local` boots the PGlite + filesystem sandbox (`src/local.ts`),
|
|
27
|
+
mounting the exact `@mediamonks/canopy-data-plane` routers the platform
|
|
28
|
+
serves — Postgres-dialect, matching the Postgres targets. ai/services still
|
|
29
|
+
proxy to the real platform when logged in.
|
|
30
|
+
|
|
31
|
+
## Working on it
|
|
32
|
+
|
|
33
|
+
The globally-installed `canopy` is the **published npm release** — it will
|
|
34
|
+
not exercise your changes. Build and use the local symlink instead:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
vp pack # in packages/cli (or `vp run dev` for --watch)
|
|
38
|
+
canopy-dev … # symlink to packages/cli/dist/index.mjs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Tests: `vp test` (includes the sandbox end-to-end suite against real
|
|
42
|
+
PGlite). Publish via the staged workflow (root CLAUDE.md), after the SDK
|
|
43
|
+
when `workspace:*` moved.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as
|
|
1
|
+
import { a as projectFlagArgs, s as resolveProject } from "./site-BJjoePQS.mjs";
|
|
2
|
+
import { i as unwrap, l as withContext, n as reportErrors, o as apiDataRequest, r as requireTty } from "./ui-DThlyHgo.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
@@ -41,7 +41,7 @@ const dbCommand = defineCommand({
|
|
|
41
41
|
const spin = p.spinner();
|
|
42
42
|
spin.start(`Exporting ${slug}…`);
|
|
43
43
|
try {
|
|
44
|
-
const { url } = await
|
|
44
|
+
const { url } = await apiDataRequest("POST", "/db/export", slug);
|
|
45
45
|
const download = await fetch(url);
|
|
46
46
|
if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
|
|
47
47
|
const sql = Buffer.from(await download.arrayBuffer());
|
|
@@ -65,7 +65,7 @@ const dbCommand = defineCommand({
|
|
|
65
65
|
spin.start(`Snapshotting ${slug}…`);
|
|
66
66
|
let created;
|
|
67
67
|
try {
|
|
68
|
-
({snapshot: created} = await
|
|
68
|
+
({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug));
|
|
69
69
|
} catch (err) {
|
|
70
70
|
spin.error("Snapshot failed.");
|
|
71
71
|
throw err;
|
|
@@ -82,7 +82,7 @@ const dbCommand = defineCommand({
|
|
|
82
82
|
args: projectFlagArgs,
|
|
83
83
|
run: reportErrors("db snapshots", async ({ args }) => {
|
|
84
84
|
const { slug } = resolveProject(args);
|
|
85
|
-
const { snapshots } = await
|
|
85
|
+
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug);
|
|
86
86
|
if (snapshots.length === 0) {
|
|
87
87
|
console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
|
|
88
88
|
return;
|
|
@@ -118,7 +118,7 @@ const dbCommand = defineCommand({
|
|
|
118
118
|
let snapshotKey = args.snapshot;
|
|
119
119
|
if (!snapshotKey && !file) {
|
|
120
120
|
requireTty("pass a local .sql dump or --snapshot <key>.");
|
|
121
|
-
const { snapshots } = await
|
|
121
|
+
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug);
|
|
122
122
|
if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
|
|
123
123
|
snapshotKey = unwrap(await p.select({
|
|
124
124
|
message: `Snapshot to restore into ${slug}`,
|
|
@@ -137,11 +137,8 @@ const dbCommand = defineCommand({
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
|
|
140
|
-
if (snapshotKey) await withContext(
|
|
141
|
-
else if (file)
|
|
142
|
-
const sql = readFileSync(file, "utf8");
|
|
143
|
-
await withContext(apiRequest("POST", `/projects/${slug}/restore`, sql, "application/sql"), restoreContext);
|
|
144
|
-
}
|
|
140
|
+
if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }), restoreContext);
|
|
141
|
+
else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql"), restoreContext);
|
|
145
142
|
p.log.success(`Restored ${slug}.`);
|
|
146
143
|
})
|
|
147
144
|
})
|
|
@@ -1,19 +1,54 @@
|
|
|
1
|
-
import { c as
|
|
2
|
-
import {
|
|
1
|
+
import { a as projectFlagArgs, c as tryReadCanopyJson, i as projectDirArgs, n as siteConfig, o as projectUrl, s as resolveProject } from "./site-BJjoePQS.mjs";
|
|
2
|
+
import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, s as apiDownload } from "./ui-DThlyHgo.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
6
|
import { join, relative, resolve } from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
7
8
|
import * as p from "@clack/prompts";
|
|
9
|
+
import * as esbuild from "esbuild";
|
|
10
|
+
//#region src/worker-bundle.ts
|
|
11
|
+
/**
|
|
12
|
+
* Bundle a project's `worker/` entry for deploy (G6).
|
|
13
|
+
* Output is a single ESM file uploaded as `_canopy/worker.mjs`.
|
|
14
|
+
*/
|
|
15
|
+
async function bundleWorker(projectDir, entry) {
|
|
16
|
+
const abs = resolve(projectDir, entry);
|
|
17
|
+
const outDir = mkdtempSync(join(tmpdir(), "canopy-worker-"));
|
|
18
|
+
const outfile = join(outDir, "worker.mjs");
|
|
19
|
+
try {
|
|
20
|
+
await esbuild.build({
|
|
21
|
+
entryPoints: [abs],
|
|
22
|
+
outfile,
|
|
23
|
+
bundle: true,
|
|
24
|
+
platform: "neutral",
|
|
25
|
+
format: "esm",
|
|
26
|
+
target: "es2022",
|
|
27
|
+
packages: "bundle",
|
|
28
|
+
logLevel: "silent"
|
|
29
|
+
});
|
|
30
|
+
return new Uint8Array(readFileSync(outfile));
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(outDir, {
|
|
33
|
+
recursive: true,
|
|
34
|
+
force: true
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
8
39
|
//#region src/deploy.ts
|
|
9
40
|
/**
|
|
10
|
-
* canopy deploy — build (optional),
|
|
11
|
-
*
|
|
12
|
-
*
|
|
41
|
+
* canopy deploy — build (optional), then the signed-upload flow
|
|
42
|
+
* (docs/09-plane-split.md D-d): the control plane authorizes the deploy and
|
|
43
|
+
* issues a short-lived grant, each file PUTs straight to the data plane, and
|
|
44
|
+
* a completion call makes the version live. Bundle bytes never transit the
|
|
45
|
+
* control plane.
|
|
13
46
|
*
|
|
14
47
|
* Reads canopy.json in the project root:
|
|
15
48
|
* { "slug": "my-app", "dist": ".", "build": "npm run build" }
|
|
16
49
|
*/
|
|
50
|
+
/** Must match apps/api/src/routes/deploys.ts WORKER_BUNDLE_PATH. */
|
|
51
|
+
const WORKER_BUNDLE_PATH = "_canopy/worker.mjs";
|
|
17
52
|
const IGNORED = /* @__PURE__ */ new Set([
|
|
18
53
|
"canopy.json",
|
|
19
54
|
"AGENTS.md",
|
|
@@ -77,25 +112,70 @@ const deployCommand = defineCommand({
|
|
|
77
112
|
throw new Error(`Output directory not found: ${distDir}`);
|
|
78
113
|
}
|
|
79
114
|
if (paths.length === 0) throw new Error(`Nothing to deploy in ${distDir}.`);
|
|
80
|
-
const
|
|
115
|
+
const files = [];
|
|
81
116
|
let total = 0;
|
|
82
117
|
for (const path of paths) {
|
|
83
118
|
const data = readFileSync(path);
|
|
84
119
|
total += data.byteLength;
|
|
85
|
-
|
|
120
|
+
files.push({
|
|
121
|
+
path: relative(distDir, path).replaceAll("\\", "/"),
|
|
122
|
+
data
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
let workerBytes = null;
|
|
126
|
+
if (config.worker) {
|
|
127
|
+
const spinWorker = p.spinner();
|
|
128
|
+
spinWorker.start(`Bundling worker ${config.worker}`);
|
|
129
|
+
try {
|
|
130
|
+
workerBytes = await bundleWorker(dir, config.worker);
|
|
131
|
+
total += workerBytes.byteLength;
|
|
132
|
+
spinWorker.stop(`Worker bundle ${(workerBytes.byteLength / 1024).toFixed(1)} kB`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
spinWorker.error("Worker bundle failed.");
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
86
137
|
}
|
|
138
|
+
const source = process.env["GITHUB_ACTIONS"] ? { "x-canopy-source": "github-action" } : {};
|
|
87
139
|
const spin = p.spinner();
|
|
88
|
-
|
|
140
|
+
const workerNote = workerBytes ? " + worker" : "";
|
|
141
|
+
spin.start(`Deploying ${files.length} files${workerNote} (${(total / 1024).toFixed(0)} kB) → ${slug}`);
|
|
89
142
|
let result;
|
|
90
143
|
try {
|
|
91
|
-
|
|
144
|
+
const { upload } = await withContext(apiRequest("POST", `/projects/${slug}/deploys`, { worker: workerBytes !== null }, void 0, source), { action: `deploy "${slug}"` });
|
|
145
|
+
const uploads = [...files, ...workerBytes ? [{
|
|
146
|
+
path: WORKER_BUNDLE_PATH,
|
|
147
|
+
data: workerBytes
|
|
148
|
+
}] : []];
|
|
149
|
+
await uploadFiles(upload.grant, uploads);
|
|
150
|
+
({deploy: result} = await withContext(apiRequest("POST", `/projects/${slug}/deploys/${upload.version}/complete`, { grant: upload.grant }, void 0, source), { action: `deploy "${slug}"` }));
|
|
92
151
|
} catch (err) {
|
|
93
152
|
spin.error("Deploy failed.");
|
|
94
153
|
throw err;
|
|
95
154
|
}
|
|
96
|
-
|
|
155
|
+
const backend = result.worker?.serviceUrl ? ` · backend ${result.worker.serviceUrl}` : "";
|
|
156
|
+
spin.stop(`Live: ${projectUrl(slug)} (v${result.version})${backend}`);
|
|
97
157
|
})
|
|
98
158
|
});
|
|
159
|
+
const UPLOAD_CONCURRENCY = 8;
|
|
160
|
+
/** PUT each file to /data/v1/uploads/<grant> — the grant is the credential. */
|
|
161
|
+
async function uploadFiles(grant, files) {
|
|
162
|
+
const { apiUrl } = siteConfig();
|
|
163
|
+
const queue = [...files];
|
|
164
|
+
async function worker() {
|
|
165
|
+
for (let file = queue.shift(); file; file = queue.shift()) {
|
|
166
|
+
const url = `${apiUrl}/data/v1/uploads/${encodeURIComponent(grant)}?path=${encodeURIComponent(file.path)}`;
|
|
167
|
+
const res = await fetch(url, {
|
|
168
|
+
method: "PUT",
|
|
169
|
+
body: Buffer.from(file.data)
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const payload = await res.json().catch(() => ({}));
|
|
173
|
+
throw new Error(`Upload of ${file.path} failed (HTTP ${res.status}${payload.error ? `, ${payload.error}` : ""}).`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await Promise.all(Array.from({ length: UPLOAD_CONCURRENCY }, worker));
|
|
178
|
+
}
|
|
99
179
|
/** Show the deploy history and ask which version to act on. */
|
|
100
180
|
async function pickVersion(deploys, live, message, initial) {
|
|
101
181
|
return unwrap(await p.select({
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { i as projectDirArgs, n as siteConfig, s as resolveProject } from "./site-BJjoePQS.mjs";
|
|
2
|
+
import { a as ApiError, c as apiRequest, l as withContext, n as reportErrors } from "./ui-DThlyHgo.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
//#region src/dev.ts
|
|
8
8
|
/**
|
|
9
|
-
* Mirror the gateway
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Mirror the gateway (apps/gateway/src/index.ts): strip /_api and forward.
|
|
10
|
+
* The plane is the path — no per-module rewriting; project scope rides on
|
|
11
|
+
* the dev token (remote) or is implicit in the sandbox (local).
|
|
12
12
|
*/
|
|
13
|
-
function rewriteApiPath(path
|
|
14
|
-
|
|
15
|
-
const query = queryStart === -1 ? "" : path.slice(queryStart);
|
|
16
|
-
let pathname = (queryStart === -1 ? path : path.slice(0, queryStart)).slice(5);
|
|
17
|
-
const dataPlane = /^\/v1\/(db|blob|ai|services)(\/.*)?$/.exec(pathname);
|
|
18
|
-
if (dataPlane) pathname = `/v1/projects/${slug}/${dataPlane[1]}${dataPlane[2] ?? ""}`;
|
|
19
|
-
return pathname + query;
|
|
13
|
+
function rewriteApiPath(path) {
|
|
14
|
+
return path.slice(5);
|
|
20
15
|
}
|
|
21
16
|
/**
|
|
22
17
|
* Load Vite's `createServer`, preferring the Vite installed in the *project*
|
|
@@ -87,12 +82,12 @@ function devTokenContext(slug) {
|
|
|
87
82
|
}
|
|
88
83
|
async function devRemote(flags, slug) {
|
|
89
84
|
const { token, expires_at } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
|
|
90
|
-
const { apiUrl } =
|
|
85
|
+
const { apiUrl } = siteConfig();
|
|
91
86
|
const server = await startVite(flags, { proxy: { "/_api": {
|
|
92
87
|
target: apiUrl,
|
|
93
88
|
changeOrigin: true,
|
|
94
89
|
headers: { authorization: `Bearer ${token}` },
|
|
95
|
-
rewrite:
|
|
90
|
+
rewrite: rewriteApiPath
|
|
96
91
|
} } });
|
|
97
92
|
console.log(`\n ${slug} — remote-first dev against LIVE project data`);
|
|
98
93
|
console.log(" (worried about a risky change? `canopy db snapshot` first, or work");
|
|
@@ -103,7 +98,7 @@ async function devRemote(flags, slug) {
|
|
|
103
98
|
async function devLocal(flags, slug) {
|
|
104
99
|
let dump;
|
|
105
100
|
if (flags.pull) {
|
|
106
|
-
const { fetchProjectDump } = await import("./local-
|
|
101
|
+
const { fetchProjectDump } = await import("./local-C5-GWJcx.mjs");
|
|
107
102
|
console.log(`Pulling a fresh export of ${slug}…`);
|
|
108
103
|
dump = await fetchProjectDump(slug);
|
|
109
104
|
}
|
|
@@ -111,18 +106,18 @@ async function devLocal(flags, slug) {
|
|
|
111
106
|
try {
|
|
112
107
|
const { token } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
|
|
113
108
|
remote = {
|
|
114
|
-
target:
|
|
109
|
+
target: siteConfig().apiUrl,
|
|
115
110
|
token
|
|
116
111
|
};
|
|
117
112
|
} catch (err) {
|
|
118
113
|
const reason = err instanceof ApiError ? err.explain().why : String(err);
|
|
119
114
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
|
|
120
115
|
}
|
|
121
|
-
const { createSandbox } = await import("./local-
|
|
116
|
+
const { createSandbox } = await import("./local-C5-GWJcx.mjs");
|
|
122
117
|
const sandbox = await createSandbox({
|
|
123
118
|
slug,
|
|
124
119
|
persistDir: join(flags.dir, ".canopy", "local"),
|
|
125
|
-
email:
|
|
120
|
+
email: siteConfig().email
|
|
126
121
|
});
|
|
127
122
|
if (dump !== void 0) {
|
|
128
123
|
await sandbox.applyDump(dump);
|
|
@@ -159,20 +154,23 @@ async function devLocal(flags, slug) {
|
|
|
159
154
|
target: remote.target,
|
|
160
155
|
changeOrigin: true,
|
|
161
156
|
headers: { authorization: `Bearer ${remote.token}` },
|
|
162
|
-
rewrite:
|
|
157
|
+
rewrite: rewriteApiPath
|
|
163
158
|
};
|
|
164
159
|
const server = await startVite(flags, { proxy: {
|
|
165
160
|
...remoteEntry && {
|
|
166
|
-
"/_api/v1/ai": remoteEntry,
|
|
167
|
-
"/_api/v1/services": remoteEntry
|
|
161
|
+
"/_api/data/v1/ai": remoteEntry,
|
|
162
|
+
"/_api/data/v1/services": remoteEntry
|
|
168
163
|
},
|
|
169
|
-
"/_api": {
|
|
164
|
+
"/_api": {
|
|
165
|
+
target: `http://127.0.0.1:${apiAddress.port}`,
|
|
166
|
+
rewrite: (path) => path
|
|
167
|
+
}
|
|
170
168
|
} });
|
|
171
169
|
server.httpServer?.once("close", () => {
|
|
172
170
|
apiServer.close();
|
|
173
171
|
sandbox.dispose();
|
|
174
172
|
});
|
|
175
|
-
console.log(`\n ${slug} — LOCAL sandbox (
|
|
173
|
+
console.log(`\n ${slug} — LOCAL sandbox (PGlite + filesystem blobs in .canopy/local/)`);
|
|
176
174
|
console.log(" data is yours alone: db and blob writes never reach the platform");
|
|
177
175
|
if (dump === void 0) console.log(" (want prod data? restart with --pull to hydrate from a fresh export)");
|
|
178
176
|
console.log(" blobs are not pulled — the local blob store starts empty");
|
|
@@ -194,7 +192,7 @@ const devCommand = defineCommand({
|
|
|
194
192
|
local: {
|
|
195
193
|
type: "boolean",
|
|
196
194
|
default: false,
|
|
197
|
-
description: "Local-data sandbox (
|
|
195
|
+
description: "Local-data sandbox (PGlite + blobs under .canopy/local/) instead of live data"
|
|
198
196
|
},
|
|
199
197
|
pull: {
|
|
200
198
|
type: "boolean",
|
package/dist/docs/ci.md
CHANGED
|
@@ -34,15 +34,19 @@ of an interactive login. A minimal GitHub Actions step:
|
|
|
34
34
|
|
|
35
35
|
```yaml
|
|
36
36
|
- run: npm install -g @mediamonks/canopy-cli
|
|
37
|
-
- run: canopy deploy
|
|
37
|
+
- run: canopy deploy
|
|
38
38
|
env:
|
|
39
39
|
CANOPY_DEPLOY_TOKEN: ${{ secrets.CANOPY_DEPLOY_TOKEN }}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
(`canopy deploy` runs the project's `canopy.json` build command by default;
|
|
43
|
+
pass `--no-build` if CI already built the output directory.)
|
|
44
|
+
|
|
42
45
|
No `canopy login` step is needed — the token is the credential.
|
|
43
46
|
|
|
44
47
|
## Notes
|
|
45
48
|
|
|
46
|
-
- A deploy token can only deploy its own project
|
|
49
|
+
- A deploy token can only deploy its own project — it reaches that project's
|
|
50
|
+
deploy endpoints and upload grants and nothing else. If it strays to another
|
|
47
51
|
project's resources, the platform rejects it.
|
|
48
52
|
- Revoke a token the moment it might be exposed; create a fresh one to replace it.
|
package/dist/docs/cli.md
CHANGED
|
@@ -35,13 +35,16 @@ is the overview.
|
|
|
35
35
|
|
|
36
36
|
- `canopy dev` — run the project locally against live platform data.
|
|
37
37
|
- `--port <port>` — dev server port.
|
|
38
|
-
- `--local` — offline sandbox (
|
|
38
|
+
- `--local` — offline sandbox (PGlite + filesystem blobs under `.canopy/local/`).
|
|
39
39
|
- `--pull` — hydrate the sandbox from a fresh prod export first (with `--local`).
|
|
40
40
|
|
|
41
41
|
## Deploy
|
|
42
42
|
|
|
43
|
-
- `canopy deploy` — build and deploy the project.
|
|
44
|
-
|
|
43
|
+
- `canopy deploy` — build and deploy the project (the `canopy.json` build
|
|
44
|
+
command runs first by default).
|
|
45
|
+
- `--no-build` — skip the build, deploy the output directory as-is.
|
|
46
|
+
- `--project <slug>` — deploy any directory, overriding (or without) a
|
|
47
|
+
`canopy.json`.
|
|
45
48
|
- `canopy rollback [version]` — make an earlier deploy live again, e.g. `v3`
|
|
46
49
|
(picked from history when omitted). `--dir <path>` to target another directory.
|
|
47
50
|
- `canopy download [version]` — download a deploy's assets as a zip, e.g. `v3`
|
|
@@ -56,6 +59,8 @@ is the overview.
|
|
|
56
59
|
- `canopy db snapshots` — list stored snapshots.
|
|
57
60
|
- `canopy db restore [file]` — replace ALL project data from a `.sql` file or
|
|
58
61
|
`--snapshot <key>`. `--yes` skips the confirmation. **Destructive.**
|
|
62
|
+
- `canopy usage` — AI spend and storage for the project (same data as the
|
|
63
|
+
dashboard Usage tab). `--project <slug>` / `--json` for agents.
|
|
59
64
|
|
|
60
65
|
## CI / deploy tokens
|
|
61
66
|
|
package/dist/docs/data.md
CHANGED
|
@@ -5,9 +5,10 @@ summary: Pulling, snapshotting, and restoring your project database.
|
|
|
5
5
|
|
|
6
6
|
# Working with project data
|
|
7
7
|
|
|
8
|
-
Each Canopy project gets its own database
|
|
9
|
-
|
|
10
|
-
to manage the data
|
|
8
|
+
Each Canopy project gets its own database and blob storage (D1 and R2 on the
|
|
9
|
+
Cloudflare target; Postgres and GCS on GCP). Your app reads and writes them
|
|
10
|
+
through the Canopy SDK at runtime; the CLI gives you tools to manage the data
|
|
11
|
+
around it.
|
|
11
12
|
|
|
12
13
|
## Collections in your app (SDK)
|
|
13
14
|
|
|
@@ -24,7 +25,24 @@ digits, and underscores only (`^[a-z][a-z0-9_]*$`). `blog_posts` is fine;
|
|
|
24
25
|
> `Invalid collection name "BlogPosts" — use lowercase letters, digits and underscores.`
|
|
25
26
|
|
|
26
27
|
Use `snake_case` for multi-word names. This mirrors the collection name straight
|
|
27
|
-
into
|
|
28
|
+
into the underlying database, so keeping it lowercase avoids case-sensitivity
|
|
29
|
+
surprises.
|
|
30
|
+
|
|
31
|
+
## Dialects and raw SQL
|
|
32
|
+
|
|
33
|
+
The document API above is portable — it behaves the same on every platform
|
|
34
|
+
target. Raw SQL is not: `canopy.db.sql()` passes your statement through
|
|
35
|
+
untranslated, so it speaks whatever the target's database speaks. Ask the
|
|
36
|
+
platform which that is:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
await canopy.db.dialect(); // "sqlite" | "postgres"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Parameterize with `?` on sqlite and `$1` on postgres. One more per-target
|
|
43
|
+
difference: `collection().search()` is real full-text search (FTS5) on sqlite
|
|
44
|
+
targets, and a plain substring match on postgres targets — different matches,
|
|
45
|
+
no ranking.
|
|
28
46
|
|
|
29
47
|
## Download a dump
|
|
30
48
|
|
package/dist/docs/deploy.md
CHANGED
|
@@ -14,19 +14,23 @@ canopy deploy
|
|
|
14
14
|
This uploads your built assets to the platform, creates a new immutable deploy
|
|
15
15
|
version, and makes it live. Each deploy is versioned (`v1`, `v2`, …).
|
|
16
16
|
|
|
17
|
+
Under the hood a deploy is three steps: the platform authorizes it and issues a
|
|
18
|
+
short-lived signed upload grant, the CLI uploads each file directly against
|
|
19
|
+
that grant, and a completion call checks the bundle and makes the new version
|
|
20
|
+
live. You only ever run `canopy deploy` — but it means a half-uploaded deploy
|
|
21
|
+
is never served.
|
|
22
|
+
|
|
17
23
|
## Building first
|
|
18
24
|
|
|
19
25
|
If your project needs a build step (React SPA, anything with a bundler), declare
|
|
20
|
-
the build command in `canopy.json`
|
|
26
|
+
the build command in `canopy.json` — `canopy deploy` runs it automatically
|
|
27
|
+
before uploading. To skip the build and upload whatever is already in the
|
|
28
|
+
output directory (or the static files as-is for a `static` project):
|
|
21
29
|
|
|
22
30
|
```bash
|
|
23
|
-
canopy deploy --build
|
|
31
|
+
canopy deploy --no-build
|
|
24
32
|
```
|
|
25
33
|
|
|
26
|
-
`--build` runs the project's configured build command before uploading. Without
|
|
27
|
-
it, `canopy deploy` uploads whatever is already built (or the static files as-is
|
|
28
|
-
for a `static` project).
|
|
29
|
-
|
|
30
34
|
## Rolling back
|
|
31
35
|
|
|
32
36
|
Every deploy is kept, so you can instantly revert:
|
package/dist/docs/dev.md
CHANGED
|
@@ -20,14 +20,15 @@ develop against realistic state.
|
|
|
20
20
|
## Offline sandbox
|
|
21
21
|
|
|
22
22
|
To develop without touching live data — for example on a plane, or before you
|
|
23
|
-
trust a destructive change — use a local sandbox backed by
|
|
23
|
+
trust a destructive change — use a local sandbox backed by PGlite (embedded
|
|
24
|
+
Postgres) and a filesystem blob store:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
27
|
canopy dev --local
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
This creates local
|
|
30
|
-
starts empty.
|
|
30
|
+
This creates local Postgres data and blobs under `.canopy/local/` in your
|
|
31
|
+
project. The sandbox starts empty.
|
|
31
32
|
|
|
32
33
|
To seed the sandbox from a fresh copy of production data first:
|
|
33
34
|
|
|
@@ -37,6 +38,12 @@ canopy dev --local --pull
|
|
|
37
38
|
|
|
38
39
|
`.canopy/local/` is disposable — delete it to reset the sandbox.
|
|
39
40
|
|
|
41
|
+
One caveat: the sandbox database is Postgres, so raw `canopy.db.sql()` speaks
|
|
42
|
+
the postgres dialect there, while your deployed project speaks whatever its
|
|
43
|
+
platform target speaks (`canopy.db.dialect()` tells you — sqlite on
|
|
44
|
+
Cloudflare). Collections (`canopy.db.collection(...)`) behave the same in both,
|
|
45
|
+
so this only matters for hand-written SQL — see `canopy docs data`.
|
|
46
|
+
|
|
40
47
|
## Which mode should an agent use?
|
|
41
48
|
|
|
42
49
|
- Iterating on UI or read-only features → `canopy dev` (live data, realistic).
|