@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,5 +1,5 @@
1
- import { o as apiDataRequest } from "./ui-CpdtK7mt.mjs";
2
- import { n as rewriteApiPath } from "./dev-BbAVZcAR.mjs";
1
+ import { o as apiDataRequest } from "./ui-2mrJcjYC.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";
@@ -126,6 +126,105 @@ const META_COLUMNS = {
126
126
  _updatedAt: "updated_at"
127
127
  };
128
128
  //#endregion
129
+ //#region ../data-plane/src/schema.ts
130
+ /** Platform/system tables never shown to users. */
131
+ const RESERVED = /^(_canopy_|_cf_|sqlite_|pg_)/;
132
+ /** Single-quoted SQL string literal (introspection args ride inline, not bound). */
133
+ const lit = (value) => `'${value.replaceAll("'", "''")}'`;
134
+ async function sqliteSchema(store) {
135
+ const names = (await store.query(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)).results.map((r) => String(r.name)).filter((n) => !RESERVED.test(n));
136
+ return Promise.all(names.map(async (name) => {
137
+ const [cols, idxList] = await Promise.all([store.query(`SELECT name, type, "notnull" AS not_null, pk FROM pragma_table_info(${lit(name)})`), store.query(`SELECT name, "unique" AS is_unique FROM pragma_index_list(${lit(name)})`)]);
138
+ const indexes = await Promise.all(idxList.results.filter((idx) => !RESERVED.test(String(idx.name))).map(async (idx) => ({
139
+ name: String(idx.name),
140
+ unique: Boolean(Number(idx.is_unique)),
141
+ columns: (await store.query(`SELECT name FROM pragma_index_info(${lit(String(idx.name))})`)).results.map((r) => String(r.name))
142
+ })));
143
+ return {
144
+ name,
145
+ columns: cols.results.map((col) => ({
146
+ name: String(col.name),
147
+ type: String(col.type),
148
+ nullable: !Number(col.not_null),
149
+ pk: Number(col.pk) > 0
150
+ })),
151
+ indexes
152
+ };
153
+ }));
154
+ }
155
+ async function postgresSchema(store) {
156
+ const [tables, columns, pks, indexes] = await Promise.all([
157
+ store.query(`SELECT table_name AS name FROM information_schema.tables
158
+ WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'
159
+ ORDER BY table_name`),
160
+ store.query(`SELECT table_name, column_name, data_type, is_nullable
161
+ FROM information_schema.columns
162
+ WHERE table_schema = current_schema()
163
+ ORDER BY table_name, ordinal_position`),
164
+ store.query(`SELECT tc.table_name, kcu.column_name
165
+ FROM information_schema.table_constraints tc
166
+ JOIN information_schema.key_column_usage kcu
167
+ ON kcu.constraint_name = tc.constraint_name
168
+ AND kcu.table_schema = tc.table_schema
169
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = current_schema()`),
170
+ store.query(`SELECT t.relname AS table_name, i.relname AS index_name,
171
+ ix.indisunique AS is_unique, a.attname AS column_name
172
+ FROM pg_index ix
173
+ JOIN pg_class i ON i.oid = ix.indexrelid
174
+ JOIN pg_class t ON t.oid = ix.indrelid
175
+ JOIN pg_namespace n ON n.oid = t.relnamespace
176
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
177
+ WHERE n.nspname = current_schema() AND NOT ix.indisprimary`)
178
+ ]);
179
+ const pkSet = new Set(pks.results.map((r) => `${String(r.table_name)}.${String(r.column_name)}`));
180
+ const byTable = /* @__PURE__ */ new Map();
181
+ for (const row of tables.results) {
182
+ const name = String(row.name);
183
+ if (!RESERVED.test(name)) byTable.set(name, {
184
+ name,
185
+ columns: [],
186
+ indexes: []
187
+ });
188
+ }
189
+ for (const col of columns.results) {
190
+ const table = byTable.get(String(col.table_name));
191
+ if (!table) continue;
192
+ table.columns.push({
193
+ name: String(col.column_name),
194
+ type: String(col.data_type),
195
+ nullable: col.is_nullable === "YES",
196
+ pk: pkSet.has(`${String(col.table_name)}.${String(col.column_name)}`)
197
+ });
198
+ }
199
+ for (const idx of indexes.results) {
200
+ const table = byTable.get(String(idx.table_name));
201
+ if (!table) continue;
202
+ const name = String(idx.index_name);
203
+ let index = table.indexes.find((i) => i.name === name);
204
+ if (!index) {
205
+ index = {
206
+ name,
207
+ unique: Boolean(idx.is_unique),
208
+ columns: []
209
+ };
210
+ table.indexes.push(index);
211
+ }
212
+ index.columns.push(String(idx.column_name));
213
+ }
214
+ return [...byTable.values()];
215
+ }
216
+ async function introspectSchema(store, dialect) {
217
+ const [tables, collections] = await Promise.all([dialect === "postgres" ? postgresSchema(store) : sqliteSchema(store), store.query("SELECT collection, COUNT(*) AS docs FROM _canopy_docs GROUP BY collection ORDER BY collection").then((r) => r.results.map((row) => ({
218
+ name: String(row.collection),
219
+ docs: Number(row.docs)
220
+ }))).catch(() => [])]);
221
+ return {
222
+ dialect,
223
+ collections,
224
+ tables
225
+ };
226
+ }
227
+ //#endregion
129
228
  //#region ../data-plane/src/db.ts
130
229
  /**
131
230
  * /db — the SDK's document API + raw-SQL escape hatch, over an injectable
@@ -143,6 +242,29 @@ const COLLECTION_RE = /^[a-z][a-z0-9_]{0,63}$/;
143
242
  const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
144
243
  const MAX_DOC_BYTES = 256 * 1024;
145
244
  const MAX_LIMIT = 1e3;
245
+ /** Row cap for versioned /db/sql responses (the legacy shape is uncapped). */
246
+ const MAX_SQL_ROWS = 1e3;
247
+ /**
248
+ * Statement classifier for read-only /db/sql runs. First keyword must be a
249
+ * read; WITH additionally may not smuggle DML in a CTE (impossible on SQLite,
250
+ * possible on Postgres — where hosts also enforce `SET TRANSACTION READ ONLY`
251
+ * at the engine, making this the belt to that suspender).
252
+ */
253
+ function readOnlySqlViolation(query) {
254
+ const bare = query.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, "\"\"");
255
+ const first = /^\s*([A-Za-z]+)/.exec(bare)?.[1]?.toUpperCase();
256
+ if (first === void 0) return "empty statement";
257
+ if (![
258
+ "SELECT",
259
+ "EXPLAIN",
260
+ "VALUES",
261
+ "WITH"
262
+ ].includes(first)) return `${first} is not allowed in read-only mode`;
263
+ const dml = /\b(INSERT|UPDATE|DELETE|MERGE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|VACUUM|ATTACH|REINDEX)\b/i.exec(bare);
264
+ if (first === "WITH" && dml) return `${dml[1].toUpperCase()} is not allowed in read-only mode`;
265
+ if (first === "EXPLAIN" && dml && /\bANALYZE\b/i.test(bare)) return "EXPLAIN ANALYZE on a write is not allowed in read-only mode";
266
+ return null;
267
+ }
146
268
  function toDoc(row) {
147
269
  return {
148
270
  ...JSON.parse(row.data),
@@ -188,16 +310,48 @@ function dbRouter(config) {
188
310
  const { store } = resolved;
189
311
  const body = await c.req.json().catch(() => ({}));
190
312
  const params = body.params ?? [];
191
- if (typeof body.query !== "string" || body.query.trim() === "" || !Array.isArray(params) || !params.every(isScalar)) return c.json({
313
+ if (typeof body.query !== "string" || body.query.trim() === "" || !Array.isArray(params) || !params.every(isScalar) || !(body.readonly === void 0 || typeof body.readonly === "boolean")) return c.json({
192
314
  error: "invalid_query",
193
- detail: "expected { query: string, params?: (string|number|boolean|null)[] }"
315
+ detail: "expected { query: string, params?: (string|number|boolean|null)[], readonly?: boolean }"
194
316
  }, 400);
195
- const result = await store.query(body.query, params).catch((err) => err);
317
+ if (body.readonly === void 0) {
318
+ const result = await store.query(body.query, params).catch((err) => err);
319
+ if (result instanceof Error) return c.json({
320
+ error: "sql_error",
321
+ detail: result.message
322
+ }, 400);
323
+ return c.json(result.results);
324
+ }
325
+ if (body.readonly) {
326
+ const violation = readOnlySqlViolation(body.query);
327
+ if (violation) return c.json({
328
+ error: "read_only",
329
+ detail: violation
330
+ }, 400);
331
+ }
332
+ const result = await store.query(body.query, params, { readonly: body.readonly }).catch((err) => err);
196
333
  if (result instanceof Error) return c.json({
197
334
  error: "sql_error",
198
335
  detail: result.message
199
336
  }, 400);
200
- return c.json(result.results);
337
+ if (!body.readonly) await config.onSqlWrite?.(c, body.query);
338
+ const truncated = result.results.length > MAX_SQL_ROWS;
339
+ const rows = truncated ? result.results.slice(0, MAX_SQL_ROWS) : result.results;
340
+ return c.json({
341
+ rows,
342
+ rowCount: rows.length,
343
+ truncated
344
+ });
345
+ });
346
+ db.get("/db/schema", async (c) => {
347
+ const resolved = await resolve(c);
348
+ if (resolved instanceof Response) return resolved;
349
+ const schema = await introspectSchema(resolved.store, resolved.dialect.name).catch((err) => err);
350
+ if (schema instanceof Error) return c.json({
351
+ error: "sql_error",
352
+ detail: schema.message
353
+ }, 400);
354
+ return c.json(schema);
201
355
  });
202
356
  db.post("/db/:collection", async (c) => {
203
357
  const resolved = await resolve(c);
@@ -1,5 +1,5 @@
1
1
  import { _ as getBakedProfile, d as clearAllProfileTokens, f as listBakedProfiles, g as writeConfigFile, h as upsertProfileAuth, m as setActiveProfile, n as siteConfig, p as readConfigFile, v as isProfileId } from "./site-BUlQR117.mjs";
2
- import { n as reportErrors, t as openBrowser } from "./ui-CpdtK7mt.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { createHash, randomBytes } from "node:crypto";
@@ -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,5 +1,5 @@
1
1
  import { c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
2
- import { c as apiRequest, l as withContext, n as reportErrors } from "./ui-CpdtK7mt.mjs";
2
+ import { c as apiRequest, n as reportErrors, u as withContext } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/logs.ts
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  import { u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
- import { o as apiDataRequest } from "./ui-CpdtK7mt.mjs";
2
+ import { o as apiDataRequest } from "./ui-2mrJcjYC.mjs";
3
3
  import { readFileSync, readdirSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  //#region src/migrations.ts
@@ -1,5 +1,5 @@
1
1
  import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-BUlQR117.mjs";
2
- import { n as reportErrors, t as openBrowser } from "./ui-CpdtK7mt.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/open.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.7.7";
2
+ var version = "0.7.9";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,5 +1,5 @@
1
1
  import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
2
- import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-CpdtK7mt.mjs";
2
+ import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import { join, relative, resolve, sep } from "node:path";
@@ -1,5 +1,5 @@
1
- import { t as version } from "./package-BNiOibhj.mjs";
2
- import { n as reportErrors } from "./ui-CpdtK7mt.mjs";
1
+ import { t as version } from "./package-CGrKr6IT.mjs";
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";
5
5
  import { join, relative } from "node:path";
@@ -1,5 +1,5 @@
1
1
  import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-BUlQR117.mjs";
2
- import { c as apiRequest, i as unwrap, l as withContext, n as reportErrors, r as requireTty } from "./ui-CpdtK7mt.mjs";
2
+ import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty, u as withContext } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  //#region src/staging.ts
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
2
- import { c as apiRequest, n as reportErrors } from "./ui-CpdtK7mt.mjs";
2
+ import { c as apiRequest, n as reportErrors } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  const tokenCommand = defineCommand({
5
5
  meta: {
@@ -102,6 +102,18 @@ const ERROR_COPY = {
102
102
  why: "the allowlist is at its 32-entry limit",
103
103
  fix: "Remove hosts you no longer need (`canopy egress list` / `canopy egress remove`), or cover several subdomains with one *. wildcard."
104
104
  },
105
+ unsupported_image: {
106
+ why: "the file isn't an image Canopy stores (PNG, JPEG, WebP or GIF)",
107
+ fix: "Attach a screenshot in one of those formats — the file's real contents are what count, not its extension."
108
+ },
109
+ attachment_too_large: {
110
+ why: "the screenshot is over the 5 MB limit",
111
+ fix: "Scale the image down (or export it as JPEG/WebP) and attach it again."
112
+ },
113
+ too_many_attachments: {
114
+ why: "the report already has the maximum number of screenshots",
115
+ fix: "Remove one from the report on the dashboard before attaching another."
116
+ },
105
117
  worker_egress_sync_failed: {
106
118
  why: "the list was saved but redeploying the running worker with it failed",
107
119
  fix: "Retry, or redeploy the project (`canopy deploy`) to apply the new allowlist."
@@ -211,6 +223,23 @@ async function apiRequest(method, path, body, rawContentType, headers) {
211
223
  if (!response.ok) return fail(response, isDeployToken, method, path);
212
224
  return await response.json();
213
225
  }
226
+ /**
227
+ * Authenticated POST of raw bytes (a screenshot attachment). The server types
228
+ * the bytes itself, so `contentType` is a hint, not a claim it trusts.
229
+ */
230
+ async function apiUpload(path, bytes, contentType) {
231
+ const { apiUrl, token, isDeployToken } = credentials();
232
+ const response = await fetch(`${apiUrl}/control/v1${path}`, {
233
+ method: "POST",
234
+ headers: {
235
+ authorization: `Bearer ${token}`,
236
+ "content-type": contentType
237
+ },
238
+ body: bytes
239
+ });
240
+ if (!response.ok) return fail(response, isDeployToken, "POST", path);
241
+ return await response.json();
242
+ }
214
243
  /** Authenticated GET returning raw bytes (e.g. a deploy zip). */
215
244
  async function apiDownload(path) {
216
245
  const { apiUrl, token, isDeployToken } = credentials();
@@ -346,4 +375,4 @@ function openBrowser(url) {
346
375
  }).on("error", () => {}).unref();
347
376
  }
348
377
  //#endregion
349
- export { ApiError as a, apiRequest as c, unwrap as i, withContext as l, reportErrors as n, apiDataRequest as o, requireTty as r, apiDownload as s, openBrowser as t };
378
+ export { ApiError as a, apiRequest as c, unwrap as i, apiUpload as l, reportErrors as n, apiDataRequest as o, requireTty as r, apiDownload as s, openBrowser as t, withContext as u };
@@ -1,5 +1,5 @@
1
1
  import { t as PACKAGE_NAME } from "./version-check-CcsA1QjI.mjs";
2
- import { n as reportErrors } from "./ui-CpdtK7mt.mjs";
2
+ import { n as reportErrors } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { spawn } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
@@ -112,7 +112,7 @@ const upgradeCommand = defineCommand({
112
112
  const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
113
113
  const command = pmCommand(pm);
114
114
  const cmd = installArgs(pm, spec);
115
- const { skillInstalled } = await import("./skill-DRTg-mJR.mjs");
115
+ const { skillInstalled } = await import("./skill-CZP1AqQX.mjs");
116
116
  const refreshSkill = skillInstalled(process.cwd());
117
117
  console.log(`Upgrading via ${command}: ${command} ${cmd.join(" ")}`);
118
118
  await run(command, cmd);
@@ -1,5 +1,5 @@
1
1
  import { c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
2
- import { c as apiRequest, l as withContext, n as reportErrors } from "./ui-CpdtK7mt.mjs";
2
+ import { c as apiRequest, n as reportErrors, u as withContext } from "./ui-2mrJcjYC.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/usage.ts
5
5
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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-BbAVZcAR.mjs";
2
- export { devCommand };