@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.
Files changed (37) hide show
  1. package/README.md +43 -0
  2. package/dist/{db-PpaU3V-B.mjs → db-BW1ZolWn.mjs} +8 -11
  3. package/dist/{deploy-B064KXtr.mjs → deploy-alC2--pZ.mjs} +92 -12
  4. package/dist/dev-BJ5xEwqW.mjs +2 -0
  5. package/dist/{dev-CuvlJpcO.mjs → dev-CQQhMOoD.mjs} +22 -24
  6. package/dist/docs/ci.md +6 -2
  7. package/dist/docs/cli.md +8 -3
  8. package/dist/docs/data.md +22 -4
  9. package/dist/docs/deploy.md +10 -6
  10. package/dist/docs/dev.md +10 -3
  11. package/dist/docs/infrastructure.md +397 -0
  12. package/dist/docs/static-serving.md +318 -0
  13. package/dist/docs/troubleshooting.md +14 -0
  14. package/dist/{docs-UYzmzpz-.mjs → docs-DvSPpSIR.mjs} +8 -7
  15. package/dist/index.mjs +53 -19
  16. package/dist/local-C5-GWJcx.mjs +826 -0
  17. package/dist/login-Bz-rwlFF.mjs +264 -0
  18. package/dist/{open-CBuZBJlv.mjs → open-DnlYDqrU.mjs} +2 -2
  19. package/dist/{package-Bnu23N0A.mjs → package-Bi5FfVSP.mjs} +1 -1
  20. package/dist/{projects-CK4DNxgZ.mjs → projects-ijfe7Ffx.mjs} +21 -3
  21. package/dist/site-BJjoePQS.mjs +254 -0
  22. package/dist/skill/canopy/SKILL.md +1 -1
  23. package/dist/{skill-h7QlCMhc.mjs → skill-IAAa2EkK.mjs} +2 -2
  24. package/dist/templates/static/AGENTS.md +1 -1
  25. package/dist/templates/static/main.js +1 -1
  26. package/dist/templates/worker/AGENTS.md +11 -9
  27. package/dist/templates/worker/worker/index.ts +27 -4
  28. package/dist/{token-yuwQTQB8.mjs → token-BeF9zczg.mjs} +2 -2
  29. package/dist/{ui-D1DXoDN1.mjs → ui-DThlyHgo.mjs} +28 -36
  30. package/dist/{upgrade-sXoqYLRu.mjs → upgrade-DNxR-zh7.mjs} +3 -3
  31. package/dist/usage-DQpH7A3X.mjs +79 -0
  32. package/package.json +4 -3
  33. package/dist/dev-BYv6BmcI.mjs +0 -2
  34. package/dist/local-B3dyT2fh.mjs +0 -563
  35. package/dist/login-DdNc9OjU.mjs +0 -173
  36. package/dist/project-DKbgJACM.mjs +0 -54
  37. 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 { c as withContext, i as unwrap, n as reportErrors, r as requireTty, s as apiRequest } from "./ui-D1DXoDN1.mjs";
2
- import { i as resolveProject, n as projectFlagArgs } from "./project-DKbgJACM.mjs";
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 apiRequest("POST", `/projects/${slug}/export`);
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 apiRequest("POST", `/projects/${slug}/snapshots`));
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 apiRequest("GET", `/projects/${slug}/snapshots`);
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 apiRequest("GET", `/projects/${slug}/snapshots`);
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(apiRequest("POST", `/projects/${slug}/restore`, { snapshot: snapshotKey }), restoreContext);
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 withContext, i as unwrap, n as reportErrors, o as apiDownload, s as apiRequest } from "./ui-D1DXoDN1.mjs";
2
- import { a as tryReadCanopyJson, i as resolveProject, n as projectFlagArgs, r as projectUrl, t as projectDirArgs } from "./project-DKbgJACM.mjs";
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 { spawn } from "node:child_process";
5
- import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
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), bundle the output directory as multipart
11
- * form-data and POST it to the control plane; the deploy is live immediately
12
- * (docs/04-roadmap.md Phase 1 item 3).
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 form = new FormData();
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
- form.append(relative(distDir, path).replaceAll("\\", "/"), new Blob([data]));
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
- spin.start(`Deploying ${paths.length} files (${(total / 1024).toFixed(0)} kB) → ${slug}`);
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
- ({deploy: result} = await withContext(apiRequest("POST", `/projects/${slug}/deploys`, form, void 0, process.env["GITHUB_ACTIONS"] ? { "x-canopy-source": "github-action" } : void 0), { action: `deploy "${slug}"` }));
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
- spin.stop(`Live: ${projectUrl(slug)} (v${result.version})`);
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({
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-CQQhMOoD.mjs";
2
+ export { devCommand };
@@ -1,22 +1,17 @@
1
- import { a as ApiError, c as withContext, l as readConfig, n as reportErrors, s as apiRequest } from "./ui-D1DXoDN1.mjs";
2
- import { i as resolveProject, t as projectDirArgs } from "./project-DKbgJACM.mjs";
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's project-host rewrite (apps/gateway/src/index.ts):
10
- * strip /_api, then scope bare data-plane module paths onto this project.
11
- * Control-plane paths (and /v1/sdk.js) pass through untouched.
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, slug) {
14
- const queryStart = path.indexOf("?");
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 } = readConfig();
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: (path) => rewriteApiPath(path, slug)
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-B3dyT2fh.mjs");
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: readConfig().apiUrl,
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-B3dyT2fh.mjs");
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: readConfig().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: (path) => rewriteApiPath(path, slug)
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": { target: `http://127.0.0.1:${apiAddress.port}` }
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 (Miniflare D1/R2 in .canopy/local/)`);
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 (Miniflare D1/R2 under .canopy/local/) instead of live data"
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 --build
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. If it strays to another
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 (Miniflare D1/R2 under `.canopy/local/`).
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
- - `--build` — run the `canopy.json` build command first.
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 (D1) and blob storage (R2). Your app
9
- reads and writes them through the Canopy SDK at runtime; the CLI gives you tools
10
- to manage the data around it.
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 SQLite storage, so keeping it lowercase avoids case-sensitivity surprises.
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
 
@@ -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` and pass `--build`:
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 Miniflare:
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 D1 and R2 under `.canopy/local/` in your project. The sandbox
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).