@mediamonks/canopy-cli 0.7.7 → 0.7.9

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/dist/{db-D3RKaLVU.mjs → db-8Etjmzxh.mjs} +2 -2
  2. package/dist/{deploy-ClioT9ZC.mjs → deploy-y75cgauK.mjs} +2 -2
  3. package/dist/dev-BYxSHUH6.mjs +2 -0
  4. package/dist/{dev-BbAVZcAR.mjs → dev-DtoZrexp.mjs} +63 -8
  5. package/dist/dev-worker-BZgtEM9V.mjs +313 -0
  6. package/dist/docs/cli-dev.md +19 -0
  7. package/dist/docs/cli-feedback.md +38 -14
  8. package/dist/docs/dev.md +19 -0
  9. package/dist/docs/overview-how-it-works.md +2 -1
  10. package/dist/docs/platform-client-access.md +2 -1
  11. package/dist/docs/platform-feedback.md +46 -11
  12. package/dist/docs/platform-members-and-access.md +2 -2
  13. package/dist/docs/platform-project-data.md +19 -9
  14. package/dist/{docs-DOiL2Sw-.mjs → docs-VmcoH1An.mjs} +2 -2
  15. package/dist/{egress-BkkG9Jzs.mjs → egress-CN_axsAN.mjs} +1 -1
  16. package/dist/feedback-DUuWXS_D.mjs +390 -0
  17. package/dist/index.mjs +24 -24
  18. package/dist/{local-BHxSZwUT.mjs → local-C5AhA4GM.mjs} +160 -6
  19. package/dist/{login-C1x1l3qg.mjs → login-D7dI8D9N.mjs} +28 -12
  20. package/dist/{logs-DuzM-_Rn.mjs → logs-DNiZquki.mjs} +1 -1
  21. package/dist/{migrations-DpWHuqkQ.mjs → migrations-C-5x6SYf.mjs} +1 -1
  22. package/dist/{open-CLSh6oez.mjs → open-DRPp4QJg.mjs} +1 -1
  23. package/dist/{package-BNiOibhj.mjs → package-CGrKr6IT.mjs} +1 -1
  24. package/dist/{projects-C9TBoiW7.mjs → projects-C6JQjVqg.mjs} +1 -1
  25. package/dist/{skill-DRTg-mJR.mjs → skill-CZP1AqQX.mjs} +2 -2
  26. package/dist/{staging-95xeXyZN.mjs → staging-Bw1D_J8M.mjs} +1 -1
  27. package/dist/templates/worker/.dev.vars.example +9 -0
  28. package/dist/templates/worker/AGENTS.md +7 -1
  29. package/dist/templates/worker/README.md +4 -1
  30. package/dist/templates/worker/_gitignore +1 -0
  31. package/dist/{token-BjlqT2Xj.mjs → token-D7zrIF8G.mjs} +1 -1
  32. package/dist/{ui-CpdtK7mt.mjs → ui-2mrJcjYC.mjs} +30 -1
  33. package/dist/{upgrade-BDaM4M6Y.mjs → upgrade-CmhSO_r6.mjs} +2 -2
  34. package/dist/{usage-wpew0a2M.mjs → usage-CX1Izj_l.mjs} +1 -1
  35. package/package.json +1 -1
  36. package/dist/dev-D6Sy15Wv.mjs +0 -2
  37. package/dist/feedback-BZG8E8zG.mjs +0 -258
@@ -1,6 +1,6 @@
1
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-CpdtK7mt.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-DpWHuqkQ.mjs";
2
+ import { i as unwrap, n as reportErrors, o as apiDataRequest, r as requireTty, u as withContext } from "./ui-2mrJcjYC.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C-5x6SYf.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { readFileSync, writeFileSync } from "node:fs";
6
6
  import * as p from "@clack/prompts";
@@ -1,6 +1,6 @@
1
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-CpdtK7mt.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-DpWHuqkQ.mjs";
2
+ import { c as apiRequest, i as unwrap, n as reportErrors, o as apiDataRequest, s as apiDownload, u as withContext } from "./ui-2mrJcjYC.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C-5x6SYf.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";
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-DtoZrexp.mjs";
2
+ export { devCommand };
@@ -1,8 +1,9 @@
1
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-CpdtK7mt.mjs";
3
- import { r as readMigrationFiles } from "./migrations-DpWHuqkQ.mjs";
2
+ import { a as ApiError, c as apiRequest, n as reportErrors, u as withContext } from "./ui-2mrJcjYC.mjs";
3
+ import { r as readMigrationFiles } from "./migrations-C-5x6SYf.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { defineCommand } from "citty";
6
+ import { existsSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { pathToFileURL } from "node:url";
8
9
  //#region src/dev.ts
@@ -15,6 +16,56 @@ function rewriteApiPath(path) {
15
16
  return path.slice(5);
16
17
  }
17
18
  /**
19
+ * What raw SQL through the worker's env.DB speaks (D15): the canopy.json lock
20
+ * wins, then the engine actually answering — PGlite in the --local sandbox
21
+ * (local.ts's default), the site's own database otherwise.
22
+ */
23
+ function workerDialect(flags) {
24
+ if (flags.dialect !== void 0) return flags.dialect;
25
+ if (flags.local) return "postgres";
26
+ return siteConfig().cloud === "gcp" ? "postgres" : "sqlite";
27
+ }
28
+ /**
29
+ * The project's own backend worker, served by the dev server the way the
30
+ * gateway serves it in production (dev-worker.ts). Returns the plugin plus
31
+ * the notice lines to print, or undefined when the project has no worker.
32
+ */
33
+ async function workerPlugin(flags, slug) {
34
+ const entry = tryReadCanopyJson(flags.dir)?.worker;
35
+ if (entry === void 0) return void 0;
36
+ if (!existsSync(join(flags.dir, entry))) {
37
+ console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
38
+ return;
39
+ }
40
+ const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-BZgtEM9V.mjs");
41
+ const secrets = readDevVars(flags.dir);
42
+ const email = siteConfig().email;
43
+ const plugin = workerDevPlugin({
44
+ dir: flags.dir,
45
+ entry,
46
+ slug,
47
+ channel: flags.staging ? "staging" : "live",
48
+ secrets,
49
+ user: email ? {
50
+ sub: "local-dev",
51
+ email,
52
+ name: "Local Dev"
53
+ } : null,
54
+ dialect: workerDialect(flags)
55
+ });
56
+ const notes = [` /api/* is served by ${entry} (edit and refresh; no build step)`];
57
+ if (Object.keys(secrets).length > 0) notes.push(` worker secrets from .dev.vars: ${Object.keys(secrets).sort().join(", ")}`);
58
+ const missing = missingSecretNames(((await apiRequest("GET", `/projects/${slug}/secrets`).catch(() => void 0))?.secrets ?? []).map((s) => s.key), Object.keys(secrets));
59
+ if (missing.length > 0) {
60
+ notes.push(` set in the dashboard but not in .dev.vars: ${missing.join(", ")}`);
61
+ notes.push(" (dashboard values are never downloaded — add local ones to .dev.vars)");
62
+ }
63
+ return {
64
+ plugin,
65
+ notes
66
+ };
67
+ }
68
+ /**
18
69
  * Load Vite's `createServer`, preferring the Vite installed in the *project*
19
70
  * over the copy bundled with the CLI. `bundled` reports which one we got.
20
71
  *
@@ -43,7 +94,7 @@ async function loadCreateServer(dir) {
43
94
  };
44
95
  }
45
96
  }
46
- async function startVite(flags, serverOverrides) {
97
+ async function startVite(flags, serverOverrides, extraPlugins = []) {
47
98
  const { createServer, bundled } = await loadCreateServer(flags.dir);
48
99
  const plugins = [{
49
100
  name: "canopy-platform-api-external",
@@ -61,7 +112,7 @@ async function startVite(flags, serverOverrides) {
61
112
  });
62
113
  const server = await createServer({
63
114
  root: flags.dir,
64
- plugins,
115
+ plugins: [...plugins, ...extraPlugins],
65
116
  server: {
66
117
  port: flags.port,
67
118
  ...serverOverrides
@@ -84,6 +135,7 @@ function devTokenContext(slug) {
84
135
  async function devRemote(flags, slug) {
85
136
  const { token, expires_at } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
86
137
  const { apiUrl } = siteConfig();
138
+ const worker = await workerPlugin(flags, slug);
87
139
  const server = await startVite(flags, { proxy: { "/_api": {
88
140
  target: apiUrl,
89
141
  changeOrigin: true,
@@ -92,7 +144,7 @@ async function devRemote(flags, slug) {
92
144
  ...flags.staging ? { "x-canopy-channel": "staging" } : {}
93
145
  },
94
146
  rewrite: rewriteApiPath
95
- } } });
147
+ } } }, worker ? [worker.plugin] : []);
96
148
  if (flags.staging) {
97
149
  console.log(`\n ${slug} — remote-first dev against the STAGING channel's data`);
98
150
  console.log(" (live data is untouched; `canopy staging sync` re-clones it from live)");
@@ -105,6 +157,7 @@ async function devRemote(flags, slug) {
105
157
  console.log(" migrations apply on `canopy deploy`, never from dev — use --local");
106
158
  console.log(" while the schema is in flux");
107
159
  }
160
+ for (const note of worker?.notes ?? []) console.log(note);
108
161
  console.log("");
109
162
  server.printUrls();
110
163
  console.log(`\n Dev token expires ${expires_at} — restart canopy dev after that.`);
@@ -112,7 +165,7 @@ async function devRemote(flags, slug) {
112
165
  async function devLocal(flags, slug) {
113
166
  let dump;
114
167
  if (flags.pull) {
115
- const { fetchProjectDump } = await import("./local-BHxSZwUT.mjs");
168
+ const { fetchProjectDump } = await import("./local-C5AhA4GM.mjs");
116
169
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
117
170
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
118
171
  }
@@ -127,7 +180,7 @@ async function devLocal(flags, slug) {
127
180
  const reason = err instanceof ApiError ? err.explain().why : String(err);
128
181
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
129
182
  }
130
- const { createSandbox } = await import("./local-BHxSZwUT.mjs");
183
+ const { createSandbox } = await import("./local-C5AhA4GM.mjs");
131
184
  const sandbox = await createSandbox({
132
185
  slug,
133
186
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -176,6 +229,7 @@ async function devLocal(flags, slug) {
176
229
  headers: { authorization: `Bearer ${remote.token}` },
177
230
  rewrite: rewriteApiPath
178
231
  };
232
+ const worker = await workerPlugin(flags, slug);
179
233
  const server = await startVite(flags, { proxy: {
180
234
  ...remoteEntry && {
181
235
  "/_api/data/v1/ai": remoteEntry,
@@ -185,7 +239,7 @@ async function devLocal(flags, slug) {
185
239
  target: `http://127.0.0.1:${apiAddress.port}`,
186
240
  rewrite: (path) => path
187
241
  }
188
- } });
242
+ } }, worker ? [worker.plugin] : []);
189
243
  server.httpServer?.once("close", () => {
190
244
  apiServer.close();
191
245
  sandbox.dispose();
@@ -197,6 +251,7 @@ async function devLocal(flags, slug) {
197
251
  if (dump === void 0) console.log(" (want prod data? restart with --pull to hydrate from a fresh export)");
198
252
  console.log(" blobs are not pulled — the local blob store starts empty");
199
253
  console.log(remote ? " ai/services pass through to the platform (metered as usual)" : " ai/services answer 501 until you log in and restart");
254
+ for (const note of worker?.notes ?? []) console.log(note);
200
255
  console.log("");
201
256
  server.printUrls();
202
257
  }
@@ -0,0 +1,313 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { relative, resolve, sep } from "node:path";
3
+ //#region src/dev-worker.ts
4
+ /**
5
+ * Paths that belong to the worker rather than the SPA — the gateway's rule
6
+ * (apps/gateway/src/backend.ts), kept identical so a route that works in dev
7
+ * is reachable in production.
8
+ */
9
+ function isWorkerPath(pathname) {
10
+ return pathname === "/api" || pathname.startsWith("/api/");
11
+ }
12
+ /** The manual schedule tick, mirroring the shim's own path (docs/07). */
13
+ const SCHEDULED_PATH = "/__canopy/scheduled";
14
+ /**
15
+ * `.dev.vars` — local-only worker secrets, the wrangler-shaped file the repo
16
+ * already gitignores.
17
+ *
18
+ * Deliberately NOT "pull the dashboard's secrets": there is no way to read a
19
+ * stored value back (apps/api/src/routes/secrets.ts lists names and
20
+ * timestamps only — values are AES-GCM at rest and injected into the worker
21
+ * runtime, nowhere else), and adding one would put every project's live
22
+ * third-party credentials on every laptop that runs `canopy dev`. North star
23
+ * principle 3, secure by default: a dev secret is a local secret.
24
+ */
25
+ function parseDevVars(text) {
26
+ const out = {};
27
+ for (const raw of text.split(/\r?\n/)) {
28
+ const line = raw.trim();
29
+ if (line === "" || line.startsWith("#")) continue;
30
+ const withoutExport = line.startsWith("export ") ? line.slice(7).trim() : line;
31
+ const eq = withoutExport.indexOf("=");
32
+ if (eq <= 0) continue;
33
+ const key = withoutExport.slice(0, eq).trim();
34
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
35
+ let value = withoutExport.slice(eq + 1).trim();
36
+ if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2 || value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1);
37
+ out[key] = value;
38
+ }
39
+ return out;
40
+ }
41
+ /** Reads `.dev.vars` from the project dir; {} when there is none. */
42
+ function readDevVars(dir) {
43
+ try {
44
+ return parseDevVars(readFileSync(resolve(dir, ".dev.vars"), "utf8"));
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+ /** Dashboard secret names the local `.dev.vars` doesn't define. */
50
+ function missingSecretNames(remote, local) {
51
+ const have = new Set(local);
52
+ return remote.filter((name) => !have.has(name)).sort();
53
+ }
54
+ /**
55
+ * Port of the shim's rewriteCanopyUrl: a worker may call
56
+ * `CANOPY.fetch("/db/todos")`, `"/data/v1/db/todos"` or the `/_api/…` form the
57
+ * browser SDK uses — all three land on the same data-plane path.
58
+ */
59
+ function rewriteCanopyUrl(raw, base) {
60
+ const root = base.replace(/\/$/, "");
61
+ let path;
62
+ let search = "";
63
+ try {
64
+ const url = new URL(raw, "http://canopy.local");
65
+ path = url.pathname;
66
+ search = url.search;
67
+ } catch {
68
+ path = raw.startsWith("/") ? raw : `/${raw}`;
69
+ }
70
+ if (path.startsWith("/_api/")) path = path.slice(5);
71
+ if (!path.startsWith("/data/v1/") && !path.startsWith("/control/v1/")) path = `/data/v1${path.startsWith("/") ? path : `/${path}`}`;
72
+ return root + path + search;
73
+ }
74
+ /**
75
+ * env.CANOPY.fetch — the same rewriting the shim does, delivered through the
76
+ * dev server's `/_api` mount so the running mode decides what answers.
77
+ * Credentials stay in the proxy; nothing here carries a token.
78
+ */
79
+ function createCanopyFetch(apiBase, slug, channel, fetchImpl = fetch) {
80
+ return async (input, init) => {
81
+ const url = rewriteCanopyUrl(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, apiBase());
82
+ const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
83
+ headers.delete("host");
84
+ headers.delete("x-canopy-user-sub");
85
+ headers.delete("x-canopy-user-email");
86
+ headers.delete("x-canopy-user-name");
87
+ headers.delete("authorization");
88
+ headers.delete("cookie");
89
+ headers.set("x-canopy-project", slug);
90
+ if (channel === "staging") headers.set("x-canopy-channel", "staging");
91
+ const method = (input instanceof Request ? input.method : init?.method) ?? "GET";
92
+ const body = input instanceof Request ? input.body : init?.body;
93
+ const outbound = {
94
+ method,
95
+ headers,
96
+ body: body ?? void 0,
97
+ redirect: input instanceof Request ? input.redirect : init?.redirect
98
+ };
99
+ if (body) outbound.duplex = "half";
100
+ return fetchImpl(url, outbound);
101
+ };
102
+ }
103
+ /**
104
+ * env.DB over the data plane's raw-SQL escape hatch — the deployed worker
105
+ * holds a direct D1/Postgres handle, which a laptop has no credential for, so
106
+ * dev routes the same statements through `/db/sql` (the legacy uncapped shape
107
+ * the SDK uses). Reads are identical; writes report `rowCount: 0` because that
108
+ * shape returns rows, not a change count.
109
+ */
110
+ function createDevDb(canopyFetch, dialect) {
111
+ return {
112
+ dialect,
113
+ query: async (text, params = []) => {
114
+ const response = await canopyFetch("/data/v1/db/sql", {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({
118
+ query: text,
119
+ params
120
+ })
121
+ });
122
+ if (!response.ok) {
123
+ const detail = await response.text();
124
+ throw new Error(`env.DB query failed (HTTP ${response.status}): ${detail.slice(0, 300)}`);
125
+ }
126
+ const rows = await response.json();
127
+ return {
128
+ rows,
129
+ rowCount: rows.length
130
+ };
131
+ }
132
+ };
133
+ }
134
+ /** env.BLOB over the data plane's blob routes, for the same reason as env.DB. */
135
+ function createDevBlob(canopyFetch, prefix) {
136
+ const path = (key) => `/data/v1/blob/${encodeURIComponent(key)}`;
137
+ return {
138
+ prefix,
139
+ put: async (key, body, contentType) => {
140
+ const response = await canopyFetch(path(key), {
141
+ method: "PUT",
142
+ headers: { "content-type": contentType ?? "application/octet-stream" },
143
+ body
144
+ });
145
+ if (!response.ok) throw new Error(`env.BLOB put failed (HTTP ${response.status})`);
146
+ },
147
+ get: async (key) => {
148
+ const response = await canopyFetch(path(key));
149
+ if (response.status === 404) return null;
150
+ if (!response.ok) throw new Error(`env.BLOB get failed (HTTP ${response.status})`);
151
+ const contentType = response.headers.get("content-type") ?? void 0;
152
+ const bytes = new Uint8Array(await response.arrayBuffer());
153
+ return contentType ? {
154
+ body: bytes,
155
+ contentType
156
+ } : { body: bytes };
157
+ },
158
+ list: async (sub) => {
159
+ const response = await canopyFetch(`/data/v1/blob${sub ? `?prefix=${encodeURIComponent(sub)}` : ""}`);
160
+ if (!response.ok) throw new Error(`env.BLOB list failed (HTTP ${response.status})`);
161
+ return (await response.json()).map(({ key, size }) => ({
162
+ key,
163
+ size
164
+ }));
165
+ },
166
+ delete: async (key) => {
167
+ const response = await canopyFetch(path(key), { method: "DELETE" });
168
+ if (!response.ok) throw new Error(`env.BLOB delete failed (HTTP ${response.status})`);
169
+ }
170
+ };
171
+ }
172
+ /** Gateway-stamped identity, honored in dev too so a request can pose as one. */
173
+ function userFromHeaders(headers, fallback) {
174
+ const sub = (headers.get("x-canopy-user-sub") ?? "").trim();
175
+ if (!sub) return fallback;
176
+ return {
177
+ sub,
178
+ email: (headers.get("x-canopy-user-email") ?? "").trim(),
179
+ name: (headers.get("x-canopy-user-name") ?? "").trim()
180
+ };
181
+ }
182
+ /**
183
+ * The env object the worker sees, in the shim's own key order: secrets first,
184
+ * platform keys after, so a stray `.dev.vars` entry can never shadow
185
+ * `CANOPY`/`DB`/`USER` (the dashboard rejects those names outright —
186
+ * apps/api/src/secrets.ts isReservedSecretKey).
187
+ */
188
+ function buildWorkerEnv(options, request, parts) {
189
+ const blobPrefix = `projects/${options.slug}/`;
190
+ return {
191
+ ...options.secrets,
192
+ CANOPY: { fetch: parts.canopyFetch },
193
+ DB: parts.db,
194
+ BLOB: parts.blob,
195
+ USER: userFromHeaders(request.headers, options.user),
196
+ PROJECT_SLUG: options.slug,
197
+ BLOB_PREFIX: blobPrefix,
198
+ CANOPY_CHANNEL: options.channel
199
+ };
200
+ }
201
+ /**
202
+ * Host to reach the dev server on from inside this process. Vite's default
203
+ * bind is `localhost`, which on this machine is IPv6-only — a hardcoded
204
+ * 127.0.0.1 gets ECONNREFUSED — so the bound address decides.
205
+ */
206
+ function loopbackHost(bound) {
207
+ if (bound === "" || bound === "::" || bound === "0.0.0.0") return "127.0.0.1";
208
+ return bound.includes(":") ? `[${bound}]` : bound;
209
+ }
210
+ /** Vite-root-relative URL for ssrLoadModule ("worker/index.ts" → "/worker/index.ts"). */
211
+ function entryModuleId(dir, entry) {
212
+ return `/${relative(resolve(dir), resolve(dir, entry)).split(sep).join("/")}`;
213
+ }
214
+ async function toRequest(req, origin) {
215
+ const headers = new Headers();
216
+ for (const [name, value] of Object.entries(req.headers)) {
217
+ if (value === void 0) continue;
218
+ for (const one of Array.isArray(value) ? value : [value]) headers.append(name, one);
219
+ }
220
+ const method = req.method ?? "GET";
221
+ let body;
222
+ if (method !== "GET" && method !== "HEAD") {
223
+ const chunks = [];
224
+ for await (const chunk of req) chunks.push(chunk);
225
+ const buffer = Buffer.concat(chunks);
226
+ if (buffer.length > 0) body = new Uint8Array(buffer);
227
+ }
228
+ return new Request(`${origin}${req.url ?? "/"}`, {
229
+ method,
230
+ headers,
231
+ body
232
+ });
233
+ }
234
+ async function sendResponse(res, response) {
235
+ const headers = {};
236
+ for (const [name, value] of response.headers) headers[name] = value;
237
+ res.writeHead(response.status, headers);
238
+ res.end(Buffer.from(await response.arrayBuffer()));
239
+ }
240
+ /**
241
+ * Serves the project's worker from the Vite dev server. Installed `pre` so
242
+ * `/api/*` is answered before Vite's SPA fallback claims it — the same
243
+ * precedence the gateway gives the worker over static assets.
244
+ */
245
+ function workerDevPlugin(options) {
246
+ const moduleId = entryModuleId(options.dir, options.entry);
247
+ let apiBase = "";
248
+ const canopyFetch = createCanopyFetch(() => apiBase, options.slug, options.channel);
249
+ return {
250
+ name: "canopy-project-worker",
251
+ configureServer(server) {
252
+ const load = async () => {
253
+ const handler = (await server.ssrLoadModule(moduleId)).default;
254
+ if (!handler || typeof handler.fetch !== "function") throw new Error(`${options.entry} must default-export an object with fetch() (Workers shape).`);
255
+ return handler;
256
+ };
257
+ server.middlewares.use((req, res, next) => {
258
+ const path = (req.url ?? "/").split("?")[0] ?? "/";
259
+ const scheduled = path === SCHEDULED_PATH;
260
+ if (!isWorkerPath(path) && !scheduled) return next();
261
+ (async () => {
262
+ const address = server.httpServer?.address();
263
+ if (apiBase === "" && address != null && typeof address === "object") apiBase = `http://${loopbackHost(address.address)}:${address.port}/_api`;
264
+ const request = await toRequest(req, `http://${req.headers.host ?? "localhost"}`);
265
+ const worker = await load();
266
+ const env = buildWorkerEnv(options, request, {
267
+ canopyFetch,
268
+ db: createDevDb(canopyFetch, options.dialect),
269
+ blob: createDevBlob(canopyFetch, `projects/${options.slug}/`)
270
+ });
271
+ if (scheduled) {
272
+ if (req.method !== "POST") return next();
273
+ if (typeof worker.scheduled !== "function") {
274
+ await sendResponse(res, Response.json({
275
+ error: "no_scheduled_handler",
276
+ detail: "export scheduled() next to fetch"
277
+ }, { status: 501 }));
278
+ return;
279
+ }
280
+ const body = await request.json().catch(() => ({}));
281
+ const name = typeof body.name === "string" ? body.name : "";
282
+ if (!name) {
283
+ await sendResponse(res, Response.json({ error: "missing_name" }, { status: 400 }));
284
+ return;
285
+ }
286
+ env.USER = null;
287
+ await worker.scheduled({
288
+ name,
289
+ cron: typeof body.cron === "string" ? body.cron : "",
290
+ scheduledTime: typeof body.scheduledTime === "number" ? body.scheduledTime : Date.now(),
291
+ type: "scheduled"
292
+ }, env);
293
+ await sendResponse(res, Response.json({
294
+ ok: true,
295
+ name
296
+ }));
297
+ return;
298
+ }
299
+ await sendResponse(res, await worker.fetch(request, env));
300
+ })().catch((err) => {
301
+ if (err instanceof Error) server.ssrFixStacktrace(err);
302
+ console.error("worker request failed", err);
303
+ sendResponse(res, Response.json({
304
+ error: "worker_error",
305
+ detail: err instanceof Error ? err.message : String(err)
306
+ }, { status: 500 })).catch(() => res.end());
307
+ });
308
+ });
309
+ }
310
+ };
311
+ }
312
+ //#endregion
313
+ export { missingSecretNames, readDevVars, workerDevPlugin };
@@ -37,6 +37,25 @@ canopy dev --local --pull # sandbox hydrated from a fresh prod export
37
37
 
38
38
  With the lock in place, `--local` runs on an embedded SQLite engine instead — the same raw SQL, full-text search behavior, and `--pull` dumps as your deployed site. `"dialect": "postgres"` makes today's default explicit. Each engine keeps its own state under `.canopy/local/`, so switching the lock never corrupts data. The SQLite engine uses Node's built-in database and needs **Node 24+** (older Node builds lack full-text search); the CLI says so plainly if yours is older.
39
39
 
40
+ **Your backend worker.** If `canopy.json` names a `worker` entry (the worker scaffold does), `canopy dev` serves it too: requests to `/api/*` go to your worker, everything else to the SPA — the same split the platform makes once you deploy. There's no build step; save the file and the next request runs the new code.
41
+
42
+ Your worker gets the same `env` it gets in production: `env.CANOPY.fetch` for the SDK, `env.DB` and `env.BLOB` for raw database and file access, `env.USER` for the signed-in person, `env.PROJECT_SLUG`. They all follow the mode you're in — live project data by default, your sandbox under `--local`. Two differences worth knowing: `env.USER` is you (the account you logged in with), since there's no company login in front of a local server, and a write through `env.DB` reports no row count locally.
43
+
44
+ To tick a schedule by hand, post its name to the same path the platform uses:
45
+
46
+ ```bash
47
+ curl -X POST localhost:5173/__canopy/scheduled -d '{"name":"nightly-digest"}'
48
+ ```
49
+
50
+ **Worker secrets.** Secrets you set in the dashboard are never downloaded — the platform can't hand a stored value back, and your laptop is not where a live API key belongs. Put local values in a `.dev.vars` file next to `canopy.json` instead:
51
+
52
+ ```
53
+ STRIPE_KEY=sk_test_local
54
+ SLACK_WEBHOOK="https://hooks.slack.com/services/…"
55
+ ```
56
+
57
+ Your worker reads them exactly as it reads dashboard secrets (`c.env.STRIPE_KEY`). Keep `.dev.vars` out of git — the scaffolds already ignore it. At startup `canopy dev` lists the dashboard secret names your `.dev.vars` doesn't define, so you know what's still missing (names only, never values).
58
+
40
59
  **`--pull`.** Before the sandbox boots, the CLI requests a fresh SQL export of the project's production database and applies it locally. Sync is pull-only — nothing ever pushes back. Blobs are not pulled; the local blob store starts empty.
41
60
 
42
61
  **Schema migrations.** When `canopy.json` configures a `"migrations"` directory (see the [migrations guide](https://docs.canopy.website/guides/migrations)), the `--local` sandbox applies pending migrations on boot, so the local schema always matches the repo. Remote-first dev **never** applies migrations — it points at production, and schema changes ship with `canopy deploy`. While a schema change is in flux, work in `--local`.
@@ -10,12 +10,16 @@ ones.
10
10
  ## Usage
11
11
 
12
12
  ```bash
13
- canopy feedback list --status accepted
13
+ canopy feedback list --status investigate
14
14
  canopy feedback show <id>
15
15
  canopy feedback comment <id> "root cause: the upload grant expires too early"
16
+ canopy feedback status <id> investigated --comment "root cause: the grant expires too early"
17
+ canopy feedback list --status accepted
16
18
  canopy feedback status <id> resolved --comment "fixed in v42"
17
19
  canopy feedback mine
18
20
  canopy feedback report "canopy deploy exits 1 after upload" --command "canopy deploy"
21
+ canopy feedback report "the header overlaps the logo" --attach ./shot.png
22
+ canopy feedback attach <id> ./after-fix.png --comment "fixed — see the shot"
19
23
  ```
20
24
 
21
25
  ## Subcommands
@@ -26,26 +30,46 @@ require project membership. `list` and `show` take `--json` for
26
30
  machine-readable output.
27
31
 
28
32
  - `list [--status <s>] [--kind <k>]` — the project's inbox. Statuses:
29
- `new`, `accepted`, `in_progress`, `resolved`, `declined`; kinds: `bug`,
30
- `feedback`, `docs`.
31
- - `show <id>` — full detail: body, captured context (page URL, deploy
32
- version, console errors — or CLI context), and the comment trail.
33
+ `investigate`, `investigated`, `accepted`, `in_progress`, `resolved`,
34
+ `declined`; kinds: `bug`, `feedback`, `docs`. (Reports filed before triage
35
+ statuses existed sit in `new`, which you can still filter for.)
36
+ - `show <id> [--download <dir>]` — full detail: body, captured context (page
37
+ URL, deploy version, console errors — or CLI context), the screenshots on
38
+ the report, and the comment trail. `--download` saves the screenshots into
39
+ a directory so you can actually look at them.
33
40
  - `comment <id> <text>` — append a comment (findings, questions, updates).
41
+ - `attach <id> <file> [--attach <more>] [--caption <text>] [--comment <text>]` —
42
+ add screenshots to an existing report. With `--comment`, the comment is
43
+ posted first and the screenshots hang off that trail entry — how you show
44
+ "here's what it looks like after the fix".
34
45
  - `status <id> <new-status> [--comment <note>]` — move the ticket; the
35
- transition is recorded on the trail.
46
+ transition is recorded on the trail. Moving to `investigated` needs the
47
+ findings: pass `--comment`, or comment first.
36
48
  - `mine [--project <slug>] [--json]` — your own reports across all projects,
37
49
  with current statuses.
38
- - `report <text> [--kind bug|feedback] [--project <slug>] [--command <cmd>] [--error <output>]` —
50
+ - `report <text> [--kind bug|feedback] [--project <slug>] [--command <cmd>] [--error <output>] [--attach <file>] [--caption <text>]` —
39
51
  file a new report. **Without `--project` it goes to the platform inbox**
40
52
  (Canopy itself) — the right target when a `canopy` command misbehaves. The
41
53
  CLI attaches its version, the active site, your platform, and the project
42
- you were working in.
54
+ you were working in. Reports open as `investigate`; a project member may
55
+ pass `--status` to open one further along — `investigated` when the report
56
+ itself is the write-up of what you found, `accepted` when the person you are
57
+ working with asked for it.
43
58
 
44
59
  ## For agents
45
60
 
46
- The intended loop: a human marks items `accepted` on the dashboard; the agent
47
- runs `list --status accepted`, reproduces using the context in `show`
48
- (including the deploy version that served the page), comments findings, and
49
- resolves — or declines with a comment saying why. When a `canopy` command
50
- itself fails: rule out user error, check `canopy feedback mine` for a
51
- duplicate, then `report` with the exact command and error output.
61
+ Two loops.
62
+
63
+ **Investigate:** `list --status investigate` is everything nobody has looked at
64
+ yet. Reproduce with the context in `show` (including the deploy version that
65
+ served the page), then `status <id> investigated --comment "<what you found>"`.
66
+ Stop there — say what it would take to fix and let the person decide.
67
+
68
+ **Build:** `list --status accepted` is what a person has asked for. Do the
69
+ work, then `resolve` with a note, or `declined` if it turns out to be a bad
70
+ idea. Set `accepted` yourself only when the person you are working with told
71
+ you to.
72
+
73
+ When a `canopy` command itself fails: rule out user error, check
74
+ `canopy feedback mine` for a duplicate, then `report` with the exact command
75
+ and error output.
package/dist/docs/dev.md CHANGED
@@ -46,6 +46,25 @@ dialect in `canopy.json` (`"dialect": "sqlite"`) and the sandbox runs an
46
46
  embedded SQLite engine instead, so the same SQL works locally and deployed —
47
47
  see [canopy dev](https://docs.canopy.website/cli/dev).
48
48
 
49
+ ## Backend worker
50
+
51
+ Projects with a `worker` entry in `canopy.json` get their backend locally too:
52
+ `/api/*` is served by your worker in both modes, with the same `env` it has in
53
+ production (`env.CANOPY`, `env.DB`, `env.BLOB`, `env.USER`). Edit and refresh —
54
+ there's no build step.
55
+
56
+ Secrets are the one thing that doesn't come from the dashboard. Stored values
57
+ are never downloadable, so put local ones in a `.dev.vars` file next to
58
+ `canopy.json`:
59
+
60
+ ```
61
+ STRIPE_KEY=sk_test_local
62
+ ```
63
+
64
+ Your worker reads them the same way it reads dashboard secrets
65
+ (`c.env.STRIPE_KEY`). `canopy dev` prints which dashboard secret names your
66
+ `.dev.vars` is missing. Keep the file out of git — the scaffolds ignore it.
67
+
49
68
  ## Which mode should an agent use?
50
69
 
51
70
  - Iterating on UI or read-only features → `canopy dev` (live data, realistic).
@@ -29,7 +29,8 @@ address exists as soon as the project does.
29
29
  Visitors to your project's URL hit OneLogin before they see anything. By
30
30
  default any Monk can open it. From the project's Settings you can tighten that
31
31
  to collaborators only, or add a shared client password so people outside Monks
32
- can view it. There is no "make it public" switch — that's by design.
32
+ can view it. The password stays visible in Settings, so you can look it up and
33
+ share it again later. There is no "make it public" switch — that's by design.
33
34
 
34
35
  ## Deploys create versions
35
36
 
@@ -28,7 +28,8 @@ The unlock is deliberately narrow:
28
28
 
29
29
  ## Enabling it
30
30
 
31
- Open the project's **Settings** tab and use the **Client access** card:
31
+ Open the project's **Settings** tab and use the **Who outside Monks can open
32
+ it** card:
32
33
 
33
34
  1. Set a password. Pick something you're comfortable saying out loud on a
34
35
  call — it's a shared secret, not a personal credential.