@mediamonks/canopy-cli 0.7.8 → 0.7.10

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.
@@ -248,7 +248,8 @@ const promoteCommand = defineCommand({
248
248
  throw err;
249
249
  }
250
250
  const backend = result.worker?.serviceUrl ? ` · backend ${result.worker.serviceUrl}` : "";
251
- spin.stop(`Live: ${projectUrl(slug)} (v${result.version}, was v${result.previous ?? "?"})${backend}`);
251
+ const was = result.previous === null ? "" : `, was v${result.previous}`;
252
+ spin.stop(`Live: ${projectUrl(slug)} (v${result.version}${was})${backend}`);
252
253
  for (const name of result.migrationsApplied) console.log(` applied ${name}`);
253
254
  if (result.snapshot !== void 0) console.log(` pre-promote snapshot: ${result.snapshot} (canopy db restore --snapshot …)`);
254
255
  })
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-DtoZrexp.mjs";
2
+ export { devCommand };
@@ -3,6 +3,7 @@ import { a as ApiError, c as apiRequest, n as reportErrors, u as withContext } f
3
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-D-HcDDzL.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-D-HcDDzL.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,9 +10,11 @@ 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"
@@ -28,8 +30,9 @@ require project membership. `list` and `show` take `--json` for
28
30
  machine-readable output.
29
31
 
30
32
  - `list [--status <s>] [--kind <k>]` — the project's inbox. Statuses:
31
- `new`, `accepted`, `in_progress`, `resolved`, `declined`; kinds: `bug`,
32
- `feedback`, `docs`.
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.)
33
36
  - `show <id> [--download <dir>]` — full detail: body, captured context (page
34
37
  URL, deploy version, console errors — or CLI context), the screenshots on
35
38
  the report, and the comment trail. `--download` saves the screenshots into
@@ -40,20 +43,33 @@ machine-readable output.
40
43
  posted first and the screenshots hang off that trail entry — how you show
41
44
  "here's what it looks like after the fix".
42
45
  - `status <id> <new-status> [--comment <note>]` — move the ticket; the
43
- 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.
44
48
  - `mine [--project <slug>] [--json]` — your own reports across all projects,
45
49
  with current statuses.
46
50
  - `report <text> [--kind bug|feedback] [--project <slug>] [--command <cmd>] [--error <output>] [--attach <file>] [--caption <text>]` —
47
51
  file a new report. **Without `--project` it goes to the platform inbox**
48
52
  (Canopy itself) — the right target when a `canopy` command misbehaves. The
49
53
  CLI attaches its version, the active site, your platform, and the project
50
- 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.
51
58
 
52
59
  ## For agents
53
60
 
54
- The intended loop: a human marks items `accepted` on the dashboard; the agent
55
- runs `list --status accepted`, reproduces using the context in `show`
56
- (including the deploy version that served the page), comments findings, and
57
- resolves — or declines with a comment saying why. When a `canopy` command
58
- itself fails: rule out user error, check `canopy feedback mine` for a
59
- 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.
@@ -55,8 +55,15 @@ report itself, and are deleted with it.
55
55
 
56
56
  Project members triage reports on the **Feedback tab**:
57
57
 
58
- - Statuses: `new → accepted → in_progress → resolved`, plus `declined`.
59
- **`accepted` means "this is valid — an agent may pick it up."**
58
+ - Statuses: `investigate → investigated → accepted → in_progress → resolved`,
59
+ plus `declined`.
60
+ - Everything arrives as **investigate** — nobody has looked at it yet. An agent
61
+ can pick those up, work out what is actually going on, and write the findings
62
+ on the report as **investigated**.
63
+ - **`accepted` means a person said "build this."** It is the one status an
64
+ agent should never reach for on its own.
65
+ - The order is a suggestion, not a track. Accept something straight away, skip
66
+ the investigation, or decline it at any point.
60
67
  - Every status change and comment is recorded on the item's trail.
61
68
  - Reporters follow their submissions on the **Reports page** (dashboard
62
69
  header) and can comment on or withdraw their own items; only project
@@ -64,21 +71,27 @@ Project members triage reports on the **Feedback tab**:
64
71
 
65
72
  ## Working the backlog with an agent
66
73
 
67
- Because the CLI exposes the inbox, "work through the accepted feedback" is a
68
- prompt, not a process:
74
+ Because the CLI exposes the inbox, "look into the new reports" and "work
75
+ through the accepted feedback" are both prompts, not processes:
69
76
 
70
77
  ```bash
71
- canopy feedback list --status accepted # what a human marked valid
72
- canopy feedback show <id> # body + captured context + trail
73
- canopy feedback comment <id> "root cause: …" # append findings
78
+ canopy feedback list --status investigate # what nobody has looked at yet
79
+ canopy feedback show <id> # body + captured context + trail
80
+ canopy feedback status <id> investigated \
81
+ --comment "root cause: the upload grant expires too early"
82
+ canopy feedback list --status accepted # what a person asked for
74
83
  canopy feedback status <id> resolved --comment "fixed in v42"
75
84
  ```
76
85
 
86
+ `investigated` always carries the findings — the note is required, because a
87
+ report that claims someone looked into it and shows nothing is worse than an
88
+ untouched one. Then it waits: **accepting is the person's call**, so an agent
89
+ sets `accepted` only when the person it is working with says to build the
90
+ thing.
91
+
77
92
  `show` includes the deploy version that served the page when the bug was
78
- reported — reproduce against that, not whatever is live now. An agent that
79
- investigates but can't fix should `comment` its findings and leave the status
80
- for a human; one that pushes back should `status <id> declined` with a comment
81
- saying why.
93
+ reported — reproduce against that, not whatever is live now. A report worth
94
+ pushing back on gets `status <id> declined` with a comment saying why.
82
95
 
83
96
  ## Reporting problems with Canopy itself
84
97
 
@@ -44,8 +44,8 @@ from the same card.
44
44
 
45
45
  Sometimes "any Monk can look" is too open — payroll prototypes, sensitive
46
46
  client work, unfinished things you'd rather not demo by accident. Flip on
47
- **Settings → Collaborators only** and the live app is limited to the
48
- project's roster:
47
+ **Settings → Who at Monks can open it → Restrict to collaborators** and the
48
+ live app is limited to the project's roster:
49
49
 
50
50
  - Other Monks who open the URL get a clear "limited to its collaborators"
51
51
  page instead of the app.
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-27IYBxoU.mjs";
1
+ import { t as version } from "./package-DRp7sI6i.mjs";
2
2
  import { n as siteConfig } from "./site-BUlQR117.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
4
  import { n as reportErrors } from "./ui-2mrJcjYC.mjs";
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-27IYBxoU.mjs";
1
+ import { t as version } from "./package-DRp7sI6i.mjs";
2
2
  import { a as projectDirArgs, c as resolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
3
3
  import { c as apiRequest, l as apiUpload, n as reportErrors, s as apiDownload } from "./ui-2mrJcjYC.mjs";
4
4
  import { defineCommand } from "citty";
@@ -9,19 +9,22 @@ import { readFile, writeFile } from "node:fs/promises";
9
9
  * canopy feedback — the ticket-lite feedback system (docs/19, docs topic
10
10
  * `platform-feedback`). Two jobs:
11
11
  *
12
- * - Work a project's inbox: `list`, `show`, `comment`, `status` — the loop an
13
- * agent runs over items a human marked `accepted`.
12
+ * - Work a project's inbox: `list`, `show`, `comment`, `status` — investigate
13
+ * what came in (`investigate` → comment the findings → `investigated`), and
14
+ * build what a human marked `accepted`.
14
15
  * - File reports: `report` sends a bug/feedback item. With no `--project` it
15
16
  * lands on the **platform inbox** (canopy itself) — the right target when a
16
17
  * canopy command misbehaves while you're building something else.
17
18
  */
18
19
  const STATUSES = [
19
- "new",
20
+ "investigate",
21
+ "investigated",
20
22
  "accepted",
21
23
  "in_progress",
22
24
  "resolved",
23
25
  "declined"
24
26
  ];
27
+ const FILTER_STATUSES = [...STATUSES, "new"];
25
28
  const KINDS = [
26
29
  "bug",
27
30
  "feedback",
@@ -94,7 +97,7 @@ const listCommand = defineCommand({
94
97
  ...jsonArg,
95
98
  status: {
96
99
  type: "string",
97
- description: `Filter: ${STATUSES.join(" | ")}`
100
+ description: `Filter: ${FILTER_STATUSES.join(" | ")}`
98
101
  },
99
102
  kind: {
100
103
  type: "string",
@@ -219,7 +222,7 @@ const statusCommand = defineCommand({
219
222
  },
220
223
  comment: {
221
224
  type: "string",
222
- description: "Optional note recorded with the change"
225
+ description: "Note recorded with the change (required for `investigated`)"
223
226
  },
224
227
  ...projectDirArgs
225
228
  },
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-27IYBxoU.mjs";
2
+ import { t as version } from "./package-DRp7sI6i.mjs";
3
3
  import { r as siteHeaderLine, t as setInvocationProfile } from "./site-BUlQR117.mjs";
4
4
  import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
5
5
  import { defineCommand, runMain } from "citty";
@@ -60,28 +60,28 @@ const main = defineCommand({
60
60
  description: "canopy — create and deploy vibecoded projects"
61
61
  },
62
62
  subCommands: {
63
- login: () => import("./login-Cxbj3F46.mjs").then((m) => m.loginCommand),
64
- whoami: () => import("./login-Cxbj3F46.mjs").then((m) => m.whoamiCommand),
65
- logout: () => import("./login-Cxbj3F46.mjs").then((m) => m.logoutCommand),
63
+ login: () => import("./login-D7dI8D9N.mjs").then((m) => m.loginCommand),
64
+ whoami: () => import("./login-D7dI8D9N.mjs").then((m) => m.whoamiCommand),
65
+ logout: () => import("./login-D7dI8D9N.mjs").then((m) => m.logoutCommand),
66
66
  create: () => import("./projects-C6JQjVqg.mjs").then((m) => m.createCommand),
67
67
  init: () => import("./projects-C6JQjVqg.mjs").then((m) => m.initCommand),
68
68
  projects: () => import("./projects-C6JQjVqg.mjs").then((m) => m.projectsCommand),
69
69
  delete: () => import("./projects-C6JQjVqg.mjs").then((m) => m.deleteCommand),
70
- dev: () => import("./dev-CQq2uzSz.mjs").then((m) => m.devCommand),
71
- deploy: () => import("./deploy-y75cgauK.mjs").then((m) => m.deployCommand),
72
- promote: () => import("./deploy-y75cgauK.mjs").then((m) => m.promoteCommand),
70
+ dev: () => import("./dev-BYxSHUH6.mjs").then((m) => m.devCommand),
71
+ deploy: () => import("./deploy-BKFICJT1.mjs").then((m) => m.deployCommand),
72
+ promote: () => import("./deploy-BKFICJT1.mjs").then((m) => m.promoteCommand),
73
73
  staging: () => import("./staging-Bw1D_J8M.mjs").then((m) => m.stagingCommand),
74
- rollback: () => import("./deploy-y75cgauK.mjs").then((m) => m.rollbackCommand),
75
- download: () => import("./deploy-y75cgauK.mjs").then((m) => m.downloadCommand),
74
+ rollback: () => import("./deploy-BKFICJT1.mjs").then((m) => m.rollbackCommand),
75
+ download: () => import("./deploy-BKFICJT1.mjs").then((m) => m.downloadCommand),
76
76
  token: () => import("./token-D7zrIF8G.mjs").then((m) => m.tokenCommand),
77
77
  egress: () => import("./egress-CN_axsAN.mjs").then((m) => m.egressCommand),
78
- feedback: () => import("./feedback-B7QOyMau.mjs").then((m) => m.feedbackCommand),
78
+ feedback: () => import("./feedback-BpiCvItH.mjs").then((m) => m.feedbackCommand),
79
79
  db: () => import("./db-8Etjmzxh.mjs").then((m) => m.dbCommand),
80
80
  usage: () => import("./usage-CX1Izj_l.mjs").then((m) => m.usageCommand),
81
81
  open: () => import("./open-DRPp4QJg.mjs").then((m) => m.openCommand),
82
- docs: () => import("./docs-59NjixYd.mjs").then((m) => m.docsCommand),
83
- skill: () => import("./skill-7K_P-5oN.mjs").then((m) => m.skillCommand),
84
- upgrade: () => import("./upgrade-CqADJbUX.mjs").then((m) => m.upgradeCommand),
82
+ docs: () => import("./docs-Ck9xogEV.mjs").then((m) => m.docsCommand),
83
+ skill: () => import("./skill-B2sDMAmv.mjs").then((m) => m.skillCommand),
84
+ upgrade: () => import("./upgrade-tOfSmAcc.mjs").then((m) => m.upgradeCommand),
85
85
  logs: () => import("./logs-DNiZquki.mjs").then((m) => m.logsCommand)
86
86
  }
87
87
  });
@@ -1,5 +1,5 @@
1
1
  import { o as apiDataRequest } from "./ui-2mrJcjYC.mjs";
2
- import { n as rewriteApiPath } from "./dev-CxfnHPI4.mjs";
2
+ import { n as rewriteApiPath } from "./dev-DtoZrexp.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";
@@ -19,27 +19,43 @@ function loginCallbackPage(ok) {
19
19
  <title>${title} · Canopy</title>
20
20
  <style>
21
21
  :root {
22
- color-scheme: light;
22
+ color-scheme: light dark;
23
23
  --ink: #14201a;
24
24
  --muted: #4a5c52;
25
25
  --line: #d5ddd7;
26
26
  --panel: rgba(255, 255, 255, 0.82);
27
+ --field: rgba(255, 255, 255, 0.7);
27
28
  --accent: #2f6b4f;
28
- --bg0: #e8f0ea;
29
- --bg1: #f7f5f0;
30
- --bg2: #dfece3;
29
+ --mark-ink: #f4faf6;
30
+ --shadow: 0 18px 40px -28px rgba(20, 32, 26, 0.45);
31
+ --bg: radial-gradient(900px 480px at 12% -10%, #dfece3, transparent 60%),
32
+ radial-gradient(700px 420px at 100% 0%, #e7efe4, transparent 55%),
33
+ linear-gradient(165deg, #e8f0ea, #f7f5f0 55%, #eef2ef);
31
34
  --mark-bg: ${ok ? "var(--accent)" : "#8a4b3a"};
32
35
  }
36
+ @media (prefers-color-scheme: dark) {
37
+ :root {
38
+ --ink: #e9f0ec;
39
+ --muted: #a2b3aa;
40
+ --line: rgba(233, 240, 236, 0.14);
41
+ --panel: rgba(24, 31, 27, 0.72);
42
+ --field: rgba(233, 240, 236, 0.06);
43
+ --accent: #7cc39d;
44
+ --mark-ink: #0d1410;
45
+ --shadow: 0 18px 44px -26px rgba(0, 0, 0, 0.7);
46
+ --bg: radial-gradient(900px 480px at 12% -10%, #16261d, transparent 60%),
47
+ radial-gradient(700px 420px at 100% 0%, #16221c, transparent 55%),
48
+ linear-gradient(165deg, #0e1512, #121a15 55%, #0d130f);
49
+ --mark-bg: ${ok ? "var(--accent)" : "#e0a292"};
50
+ }
51
+ }
33
52
  * { box-sizing: border-box; }
34
53
  body {
35
54
  margin: 0;
36
55
  min-height: 100vh;
37
56
  font-family: "Segoe UI", "Helvetica Neue", ui-sans-serif, system-ui, sans-serif;
38
57
  color: var(--ink);
39
- background:
40
- radial-gradient(900px 480px at 12% -10%, var(--bg2), transparent 60%),
41
- radial-gradient(700px 420px at 100% 0%, #e7efe4, transparent 55%),
42
- linear-gradient(165deg, var(--bg0), var(--bg1) 55%, #eef2ef);
58
+ background: var(--bg);
43
59
  display: grid;
44
60
  place-items: center;
45
61
  padding: 1.5rem;
@@ -51,7 +67,7 @@ function loginCallbackPage(ok) {
51
67
  border-radius: 1.25rem;
52
68
  padding: 2rem 1.75rem 1.75rem;
53
69
  backdrop-filter: blur(10px);
54
- box-shadow: 0 18px 40px -28px rgba(20, 32, 26, 0.45);
70
+ box-shadow: var(--shadow);
55
71
  }
56
72
  .mark {
57
73
  width: 2.75rem;
@@ -60,7 +76,7 @@ function loginCallbackPage(ok) {
60
76
  display: grid;
61
77
  place-items: center;
62
78
  background: var(--mark-bg);
63
- color: #f4faf6;
79
+ color: var(--mark-ink);
64
80
  margin-bottom: 1.25rem;
65
81
  }
66
82
  .mark svg { width: 1.4rem; height: 1.4rem; display: block; }
@@ -98,7 +114,7 @@ function loginCallbackPage(ok) {
98
114
  padding: 0.1em 0.35em;
99
115
  border: 1px solid var(--line);
100
116
  border-radius: 0.35rem;
101
- background: rgba(255, 255, 255, 0.7);
117
+ background: var(--field);
102
118
  }
103
119
  </style>
104
120
  </head>
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.7.8";
2
+ var version = "0.7.10";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-27IYBxoU.mjs";
1
+ import { t as version } from "./package-DRp7sI6i.mjs";
2
2
  import { n as reportErrors } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -0,0 +1,9 @@
1
+ # Local worker secrets for `canopy dev` — copy to .dev.vars (gitignored).
2
+ #
3
+ # Dashboard secrets (Settings → Secrets) are never downloaded: the platform
4
+ # can't hand a stored value back, and live keys don't belong on a laptop. Set
5
+ # local stand-ins here; the worker reads them as c.env.NAME, exactly as it
6
+ # reads the dashboard's in production. `canopy dev` prints which dashboard
7
+ # secret names this file is missing.
8
+ #
9
+ # MY_API_KEY=local-test-key
@@ -28,6 +28,10 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
28
28
  `event.name`). It runs behind the Canopy gateway (HTTP requests arrive
29
29
  authenticated) and reaches the platform through a service binding — same
30
30
  SDK, same budgets and data isolation as the browser.
31
+ - `canopy dev` serves the worker as well: `/api/*` goes to
32
+ `worker/index.ts` with the same `env` it gets deployed, no build step. Tick a
33
+ schedule by hand with
34
+ `curl -X POST localhost:5173/__canopy/scheduled -d '{"name":"<schedule>"}'`.
31
35
  - The SDK comes from npm (`@mediamonks/canopy`) — the browser's shared
32
36
  instance lives in `src/canopy.ts`.
33
37
  - `canopy dev` uses LIVE project data: read freely, but take a
@@ -50,7 +54,9 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
50
54
  reaches this app or the Worker.
51
55
  - Do not store secrets in the repo or SPA. Set them in the Canopy dashboard
52
56
  (Settings → Secrets); the Worker reads them as `c.env.SECRET_NAME`
53
- (Workers-shaped bindings — not `process.env`). Platform access still goes
57
+ (Workers-shaped bindings — not `process.env`). Locally, put stand-in values
58
+ in `.dev.vars` (gitignored, see `.dev.vars.example`) — dashboard values are
59
+ never downloaded, and `canopy dev` prints which names are still missing. Platform access still goes
54
60
  through the Canopy SDK (`canopy.auth`, `canopy.db`, `canopy.blob`,
55
61
  `canopy.ai`) in both the browser and the Worker.
56
62
  - The Worker is the only backend: no SSR, no long-running processes, no
@@ -5,7 +5,10 @@ backend Worker (`worker/`). Live (behind company login) at
5
5
  https://{{slug}}.thanarie.dev after the first `canopy deploy`.
6
6
 
7
7
  - `npm install` — once, then:
8
- - `canopy dev` — develop locally against the live platform (real data!)
8
+ - `canopy dev` — develop locally against the live platform (real data!);
9
+ `/api/*` is served by `worker/index.ts`, everything else by the SPA
10
+ - `.dev.vars` — local values for the worker's secrets (copy
11
+ `.dev.vars.example`); dashboard secrets are never downloaded
9
12
  - `canopy dev --local [--pull]` — offline sandbox with local data instead
10
13
  - `canopy deploy` — build and publish the frontend and the `worker/` backend
11
14
  - `AGENTS.md` — project guardrails and skill setup for coding agents
@@ -1,4 +1,5 @@
1
1
  node_modules/
2
2
  dist/
3
3
  .canopy/
4
+ .dev.vars
4
5
  .DS_Store
@@ -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-7K_P-5oN.mjs");
115
+ const { skillInstalled } = await import("./skill-B2sDMAmv.mjs");
116
116
  const refreshSkill = skillInstalled(process.cwd());
117
117
  console.log(`Upgrading via ${command}: ${command} ${cmd.join(" ")}`);
118
118
  await run(command, cmd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -1,2 +0,0 @@
1
- import { t as devCommand } from "./dev-CxfnHPI4.mjs";
2
- export { devCommand };