@mediamonks/canopy-cli 0.7.6 → 0.7.8

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 (28) 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-CQq2uzSz.mjs +2 -0
  4. package/dist/{dev-BbAVZcAR.mjs → dev-CxfnHPI4.mjs} +4 -4
  5. package/dist/docs/cli-feedback.md +59 -0
  6. package/dist/docs/index.json +10 -0
  7. package/dist/docs/platform-feedback.md +101 -0
  8. package/dist/docs/platform-project-data.md +19 -9
  9. package/dist/{docs-DhAz6QWS.mjs → docs-59NjixYd.mjs} +2 -2
  10. package/dist/{egress-BkkG9Jzs.mjs → egress-CN_axsAN.mjs} +1 -1
  11. package/dist/feedback-B7QOyMau.mjs +387 -0
  12. package/dist/index.mjs +24 -23
  13. package/dist/{local-BHxSZwUT.mjs → local-D-HcDDzL.mjs} +160 -6
  14. package/dist/{login-C1x1l3qg.mjs → login-Cxbj3F46.mjs} +1 -1
  15. package/dist/{logs-DuzM-_Rn.mjs → logs-DNiZquki.mjs} +1 -1
  16. package/dist/{migrations-DpWHuqkQ.mjs → migrations-C-5x6SYf.mjs} +1 -1
  17. package/dist/{open-CLSh6oez.mjs → open-DRPp4QJg.mjs} +1 -1
  18. package/dist/{package-CPXtNnfc.mjs → package-27IYBxoU.mjs} +1 -1
  19. package/dist/{projects-C9TBoiW7.mjs → projects-C6JQjVqg.mjs} +1 -1
  20. package/dist/skill/canopy/SKILL.md +7 -0
  21. package/dist/{skill-BCX4cUeP.mjs → skill-7K_P-5oN.mjs} +2 -2
  22. package/dist/{staging-95xeXyZN.mjs → staging-Bw1D_J8M.mjs} +1 -1
  23. package/dist/{token-BjlqT2Xj.mjs → token-D7zrIF8G.mjs} +1 -1
  24. package/dist/{ui-CpdtK7mt.mjs → ui-2mrJcjYC.mjs} +30 -1
  25. package/dist/{upgrade-jLu7jI0r.mjs → upgrade-CqADJbUX.mjs} +2 -2
  26. package/dist/{usage-wpew0a2M.mjs → usage-CX1Izj_l.mjs} +1 -1
  27. package/package.json +3 -3
  28. package/dist/dev-D6Sy15Wv.mjs +0 -2
@@ -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-CxfnHPI4.mjs";
2
+ export { devCommand };
@@ -1,6 +1,6 @@
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
6
  import { join } from "node:path";
@@ -112,7 +112,7 @@ async function devRemote(flags, slug) {
112
112
  async function devLocal(flags, slug) {
113
113
  let dump;
114
114
  if (flags.pull) {
115
- const { fetchProjectDump } = await import("./local-BHxSZwUT.mjs");
115
+ const { fetchProjectDump } = await import("./local-D-HcDDzL.mjs");
116
116
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
117
117
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
118
118
  }
@@ -127,7 +127,7 @@ async function devLocal(flags, slug) {
127
127
  const reason = err instanceof ApiError ? err.explain().why : String(err);
128
128
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
129
129
  }
130
- const { createSandbox } = await import("./local-BHxSZwUT.mjs");
130
+ const { createSandbox } = await import("./local-D-HcDDzL.mjs");
131
131
  const sandbox = await createSandbox({
132
132
  slug,
133
133
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -0,0 +1,59 @@
1
+ ---
2
+ title: canopy feedback
3
+ description: Read and triage a project's feedback inbox, or file a bug/feedback report — against a project or against Canopy itself.
4
+ ---
5
+
6
+ `canopy feedback` is the CLI side of [Feedback & bug reports](https://docs.canopy.website/platform/feedback):
7
+ agents (and humans) pull reports, append findings, move statuses, and file new
8
+ ones.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ canopy feedback list --status accepted
14
+ canopy feedback show <id>
15
+ canopy feedback comment <id> "root cause: the upload grant expires too early"
16
+ canopy feedback status <id> resolved --comment "fixed in v42"
17
+ canopy feedback mine
18
+ canopy feedback report "canopy deploy exits 1 after upload" --command "canopy deploy"
19
+ canopy feedback report "the header overlaps the logo" --attach ./shot.png
20
+ canopy feedback attach <id> ./after-fix.png --comment "fixed — see the shot"
21
+ ```
22
+
23
+ ## Subcommands
24
+
25
+ Inbox commands (`list`, `show`, `comment`, `status`) resolve the project the
26
+ usual way — `canopy.json` in the target directory, or `--project <slug>` — and
27
+ require project membership. `list` and `show` take `--json` for
28
+ machine-readable output.
29
+
30
+ - `list [--status <s>] [--kind <k>]` — the project's inbox. Statuses:
31
+ `new`, `accepted`, `in_progress`, `resolved`, `declined`; kinds: `bug`,
32
+ `feedback`, `docs`.
33
+ - `show <id> [--download <dir>]` — full detail: body, captured context (page
34
+ URL, deploy version, console errors — or CLI context), the screenshots on
35
+ the report, and the comment trail. `--download` saves the screenshots into
36
+ a directory so you can actually look at them.
37
+ - `comment <id> <text>` — append a comment (findings, questions, updates).
38
+ - `attach <id> <file> [--attach <more>] [--caption <text>] [--comment <text>]` —
39
+ add screenshots to an existing report. With `--comment`, the comment is
40
+ posted first and the screenshots hang off that trail entry — how you show
41
+ "here's what it looks like after the fix".
42
+ - `status <id> <new-status> [--comment <note>]` — move the ticket; the
43
+ transition is recorded on the trail.
44
+ - `mine [--project <slug>] [--json]` — your own reports across all projects,
45
+ with current statuses.
46
+ - `report <text> [--kind bug|feedback] [--project <slug>] [--command <cmd>] [--error <output>] [--attach <file>] [--caption <text>]` —
47
+ file a new report. **Without `--project` it goes to the platform inbox**
48
+ (Canopy itself) — the right target when a `canopy` command misbehaves. The
49
+ CLI attaches its version, the active site, your platform, and the project
50
+ you were working in.
51
+
52
+ ## For agents
53
+
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.
@@ -129,6 +129,11 @@
129
129
  "title": "Members & access",
130
130
  "summary": "Everything on Canopy sits behind Monks sign-in. Collaborators manage a project; other Monks can view the live app — unless you tighten it."
131
131
  },
132
+ {
133
+ "slug": "platform-feedback",
134
+ "title": "Feedback & bug reports",
135
+ "summary": "A built-in feedback widget on every project, a ticket-lite inbox behind it, and a CLI that lets agents work the backlog."
136
+ },
132
137
  {
133
138
  "slug": "platform-client-access",
134
139
  "title": "Client access",
@@ -241,6 +246,11 @@
241
246
  "title": "canopy usage",
242
247
  "summary": "Show a project's AI spend, storage footprint, and deploy count from the terminal."
243
248
  },
249
+ {
250
+ "slug": "cli-feedback",
251
+ "title": "canopy feedback",
252
+ "summary": "Read and triage a project's feedback inbox, or file a bug/feedback report — against a project or against Canopy itself."
253
+ },
244
254
  {
245
255
  "slug": "cli-open",
246
256
  "title": "canopy open",
@@ -0,0 +1,101 @@
1
+ ---
2
+ title: Feedback & bug reports
3
+ description: A built-in feedback widget on every project, a ticket-lite inbox behind it, and a CLI that lets agents work the backlog.
4
+ ---
5
+
6
+ Every Canopy project gets an in-app feedback channel without writing a line of
7
+ code: signed-in Monks see a small floating button on the live app, pick **Bug**
8
+ or **Feedback**, type what happened, and send. The report lands in the
9
+ project's **Feedback tab** on the dashboard, with everything an investigator
10
+ needs captured automatically — page URL, the deploy version that served it,
11
+ viewport, and recent console errors.
12
+
13
+ It is deliberately **not** a ticketing product: an item has a status, an
14
+ append-only comment trail, and nothing else. No assignees, priorities, or
15
+ boards.
16
+
17
+ ## The widget
18
+
19
+ - Appears only for **signed-in Monks**. Client-password visitors never see it —
20
+ the platform doesn't even send them the script.
21
+ - On by default for every project. Turn it off per project in
22
+ **Settings → Feedback widget**; the change applies on the next page load.
23
+ - Submissions are always identified (your company sign-in), so there's no
24
+ anonymous spam and no reporter field to fill in.
25
+ - The widget also shows your own previous reports for that project with their
26
+ current status.
27
+ - A page can reshape the chooser with meta tags — this docs site does exactly
28
+ that: `<meta name="canopy-feedback-kinds" content="docs,feedback">` swaps
29
+ "Bug" for "Docs issue", and `canopy-feedback-prompt` replaces the textarea
30
+ placeholder. Kinds are limited to `bug`, `feedback`, `docs`.
31
+
32
+ ## Screenshots
33
+
34
+ A screenshot is often the whole report, so you can attach up to six to any
35
+ report:
36
+
37
+ - **In the widget:** click **Attach screenshot**, or paste one straight from
38
+ the clipboard into the message box, or drop an image on the panel. Thumbnails
39
+ show what will be sent, and each one can be removed before you hit Send.
40
+ - **On the dashboard:** the same three ways, from the report itself — so you can
41
+ add a picture to a report after filing it, or to a comment as you write it
42
+ ("here's what it looks like after the fix"). Click any thumbnail for the full
43
+ image, then use the arrow keys to page through the rest.
44
+ - **From the CLI:** `canopy feedback report "…" --attach ./shot.png`, or
45
+ `canopy feedback attach <id> ./after-fix.png` for a report that already
46
+ exists. `canopy feedback show <id> --download <dir>` saves a report's
47
+ screenshots so an agent can look at them.
48
+
49
+ PNG, JPEG, WebP and GIF, up to 5 MB each. Large screenshots are scaled down in
50
+ your browser before they're sent, so a full-screen retina capture goes up small
51
+ without you doing anything. Screenshots are visible to the same people as the
52
+ report itself, and are deleted with it.
53
+
54
+ ## The inbox
55
+
56
+ Project members triage reports on the **Feedback tab**:
57
+
58
+ - Statuses: `new → accepted → in_progress → resolved`, plus `declined`.
59
+ **`accepted` means "this is valid — an agent may pick it up."**
60
+ - Every status change and comment is recorded on the item's trail.
61
+ - Reporters follow their submissions on the **Reports page** (dashboard
62
+ header) and can comment on or withdraw their own items; only project
63
+ members change statuses.
64
+
65
+ ## Working the backlog with an agent
66
+
67
+ Because the CLI exposes the inbox, "work through the accepted feedback" is a
68
+ prompt, not a process:
69
+
70
+ ```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
74
+ canopy feedback status <id> resolved --comment "fixed in v42"
75
+ ```
76
+
77
+ `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.
82
+
83
+ ## Reporting problems with Canopy itself
84
+
85
+ When a `canopy` command fails while you (or your agent) are building a
86
+ project, file it against the **platform inbox** — not your own project:
87
+
88
+ ```bash
89
+ canopy feedback report "deploy failed after upload" \
90
+ --command "canopy deploy --staging" --error "upload_grant_expired …"
91
+ ```
92
+
93
+ With no `--project`, the report goes to the platform team automatically, with
94
+ your CLI version, site, and the project you were working on attached. Before
95
+ filing: rule out user error first (`canopy docs troubleshooting`,
96
+ `vp env doctor` for toolchain issues), and skim `canopy feedback mine` to
97
+ avoid filing the same issue twice.
98
+
99
+ ## CLI reference
100
+
101
+ See [canopy feedback](https://docs.canopy.website/cli/feedback) for every subcommand and flag.
@@ -8,17 +8,27 @@ created automatically the moment the project exists. There are no connection
8
8
  strings to configure and no servers to run — your app reads and writes through
9
9
  the Canopy SDK, and no other project can ever see your data.
10
10
 
11
- ## The Data tab
11
+ ## The Data tab and the data explorer
12
12
 
13
13
  Open a project in the dashboard and the **Data** tab shows what your app has
14
- stored:
15
-
16
- - **Collections** — the document-style data your app writes with
17
- `canopy.db.collection(...)`. Pick a collection and page through its rows.
18
- - **Tables** — a browser for the underlying database tables, for when your
19
- app uses raw SQL or you want to see exactly what's on disk.
20
- - **SQL console** — run a query directly against the project's database from
21
- the browser. Useful for quick checks and one-off fixes.
14
+ stored — every collection and table at a glance — plus the door into the
15
+ **data explorer**, a full-page workspace for actually digging in:
16
+
17
+ - **Schema sidebar** — collections and tables, each expandable to its
18
+ columns (types, primary keys) and indexes.
19
+ - **Browse** — click any table or collection to page through it with real
20
+ totals, sort by any column, and stack filters (equals, contains,
21
+ comparisons, null checks) without writing SQL. Click a row for the full
22
+ record, JSON pretty-printed. Export the current page as CSV or JSON.
23
+ - **SQL console** — a real editor with syntax highlighting and autocomplete
24
+ fed by your schema. Run with <kbd>⌘⏎</kbd>, see row counts and timing,
25
+ recall earlier queries from history, and hit **Explain** to see the query
26
+ plan.
27
+
28
+ The console is **read-only by default** — the platform rejects anything but
29
+ reads until you flip the writes toggle, so exploring production data is safe
30
+ by design. The explorer is fixed to the channel (live or staging) you opened
31
+ it from; to look at the other one, go back to the Data tab and switch there.
22
32
 
23
33
  The **Files** tab next to it lists the project's blob storage — the uploads
24
34
  and files your app has saved.
@@ -1,7 +1,7 @@
1
- import { t as version } from "./package-CPXtNnfc.mjs";
1
+ import { t as version } from "./package-27IYBxoU.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
- import { n as reportErrors } from "./ui-CpdtK7mt.mjs";
4
+ import { n as reportErrors } from "./ui-2mrJcjYC.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
@@ -1,5 +1,5 @@
1
1
  import { a as projectDirArgs, c as resolveProject } 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
  //#region src/egress.ts
5
5
  /**
@@ -0,0 +1,387 @@
1
+ import { t as version } from "./package-27IYBxoU.mjs";
2
+ import { a as projectDirArgs, c as resolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
3
+ import { c as apiRequest, l as apiUpload, n as reportErrors, s as apiDownload } from "./ui-2mrJcjYC.mjs";
4
+ import { defineCommand } from "citty";
5
+ import { basename, extname, join } from "node:path";
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ //#region src/feedback.ts
8
+ /**
9
+ * canopy feedback — the ticket-lite feedback system (docs/19, docs topic
10
+ * `platform-feedback`). Two jobs:
11
+ *
12
+ * - Work a project's inbox: `list`, `show`, `comment`, `status` — the loop an
13
+ * agent runs over items a human marked `accepted`.
14
+ * - File reports: `report` sends a bug/feedback item. With no `--project` it
15
+ * lands on the **platform inbox** (canopy itself) — the right target when a
16
+ * canopy command misbehaves while you're building something else.
17
+ */
18
+ const STATUSES = [
19
+ "new",
20
+ "accepted",
21
+ "in_progress",
22
+ "resolved",
23
+ "declined"
24
+ ];
25
+ const KINDS = [
26
+ "bug",
27
+ "feedback",
28
+ "docs"
29
+ ];
30
+ /** Content-type hint from the extension; the server types the bytes itself. */
31
+ const IMAGE_TYPES = {
32
+ ".png": "image/png",
33
+ ".jpg": "image/jpeg",
34
+ ".jpeg": "image/jpeg",
35
+ ".webp": "image/webp",
36
+ ".gif": "image/gif"
37
+ };
38
+ /**
39
+ * Every `--attach` on the command line. citty 0.2.2 has no array arg type and
40
+ * keeps only the last occurrence, so repeats are read straight from argv;
41
+ * `--attach a.png,b.png` works as well, since agents write it both ways.
42
+ */
43
+ function attachedPaths(value) {
44
+ const flag = "--attach";
45
+ const repeated = process.argv.flatMap((arg, index) => {
46
+ if (arg === flag) {
47
+ const next = process.argv[index + 1];
48
+ return next && !next.startsWith("-") ? [next] : [];
49
+ }
50
+ return arg.startsWith(`${flag}=`) ? [arg.slice(9)] : [];
51
+ });
52
+ return (repeated.length > 0 ? repeated : value === void 0 ? [] : [String(value)]).flatMap((entry) => entry.split(",")).map((path) => path.trim()).filter((path) => path !== "");
53
+ }
54
+ /** Upload each file to a report that already exists (docs/21 create-then-attach). */
55
+ async function attachFiles(slug, itemId, paths, caption, commentId) {
56
+ for (const [index, path] of paths.entries()) {
57
+ const bytes = await readFile(path);
58
+ const params = new URLSearchParams({ filename: basename(path) });
59
+ if (caption) params.set("caption", paths.length > 1 ? `${caption} (${index + 1})` : caption);
60
+ if (commentId) params.set("comment_id", commentId);
61
+ await apiUpload(`/projects/${slug}/feedback/${encodeURIComponent(itemId)}/attachments?${params}`, bytes, IMAGE_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream");
62
+ console.log(`Attached ${basename(path)}.`);
63
+ }
64
+ }
65
+ function sizeLabel(bytes) {
66
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
67
+ }
68
+ const jsonArg = { json: {
69
+ type: "boolean",
70
+ description: "Machine-readable JSON output"
71
+ } };
72
+ function firstLine(body) {
73
+ const line = body.split("\n")[0] ?? "";
74
+ return line.length > 72 ? `${line.slice(0, 71)}…` : line;
75
+ }
76
+ function printItems(items) {
77
+ if (items.length === 0) {
78
+ console.log("No matching feedback items.");
79
+ return;
80
+ }
81
+ for (const item of items) {
82
+ const comments = item.comment_count ? ` 💬${item.comment_count}` : "";
83
+ const shots = item.attachment_count ? ` 📎${item.attachment_count}` : "";
84
+ console.log(`${item.id} ${item.status} ${item.kind} ${firstLine(item.body)}${comments}${shots}`);
85
+ }
86
+ }
87
+ const listCommand = defineCommand({
88
+ meta: {
89
+ name: "list",
90
+ description: "List the project's feedback inbox (members only)"
91
+ },
92
+ args: {
93
+ ...projectDirArgs,
94
+ ...jsonArg,
95
+ status: {
96
+ type: "string",
97
+ description: `Filter: ${STATUSES.join(" | ")}`
98
+ },
99
+ kind: {
100
+ type: "string",
101
+ description: `Filter: ${KINDS.join(" | ")}`
102
+ }
103
+ },
104
+ run: reportErrors("feedback list", async ({ args }) => {
105
+ const { slug } = resolveProject(args);
106
+ const params = new URLSearchParams();
107
+ if (args.status) params.set("status", args.status);
108
+ if (args.kind) params.set("kind", args.kind);
109
+ const { items } = await apiRequest("GET", `/projects/${slug}/feedback${params.size > 0 ? `?${params}` : ""}`);
110
+ if (args.json) console.log(JSON.stringify(items, null, 2));
111
+ else printItems(items);
112
+ })
113
+ });
114
+ const showCommand = defineCommand({
115
+ meta: {
116
+ name: "show",
117
+ description: "Full detail of one item: body, context, comment trail"
118
+ },
119
+ args: {
120
+ id: {
121
+ type: "positional",
122
+ description: "Item id (from `canopy feedback list`)",
123
+ required: true
124
+ },
125
+ ...projectDirArgs,
126
+ ...jsonArg,
127
+ download: {
128
+ type: "string",
129
+ description: "Save the report's screenshots into this directory"
130
+ }
131
+ },
132
+ run: reportErrors("feedback show", async ({ args }) => {
133
+ const { slug } = resolveProject(args);
134
+ const detail = await apiRequest("GET", `/projects/${slug}/feedback/${encodeURIComponent(args.id)}`);
135
+ if (args.json) {
136
+ console.log(JSON.stringify(detail, null, 2));
137
+ return;
138
+ }
139
+ const { item, comments } = detail;
140
+ console.log(`${item.kind} · ${item.status} · via ${item.source} · ${item.created_at}`);
141
+ console.log(`reported by ${item.reporter_name || item.reporter_email || "unknown"}` + (item.deployment_id ? ` on ${item.deployment_id}` : "") + (item.channel === "staging" ? " (staging)" : ""));
142
+ console.log(`\n${item.body}\n`);
143
+ if (item.context) {
144
+ console.log("context:");
145
+ try {
146
+ console.log(JSON.stringify(JSON.parse(item.context), null, 2));
147
+ } catch {
148
+ console.log(item.context);
149
+ }
150
+ console.log("");
151
+ }
152
+ const shots = detail.attachments;
153
+ if (shots.length > 0) {
154
+ console.log("screenshots:");
155
+ for (const shot of shots) {
156
+ const label = shot.caption ? ` — ${shot.caption}` : "";
157
+ console.log(` ${shot.id} ${shot.filename ?? "screenshot"} ${sizeLabel(shot.size_bytes)}${label}`);
158
+ }
159
+ if (!args.download) console.log(" (save them with --download <dir> to look at them)");
160
+ console.log("");
161
+ }
162
+ for (const entry of comments) {
163
+ const author = entry.author_name || entry.author_email || "unknown";
164
+ const move = entry.status_to ? ` [${entry.status_from} → ${entry.status_to}]` : "";
165
+ console.log(`--- ${author} · ${entry.created_at}${move}`);
166
+ if (entry.body) console.log(entry.body);
167
+ }
168
+ if (args.download && shots.length > 0) {
169
+ console.log("");
170
+ for (const shot of shots) {
171
+ const bytes = await apiDownload(`/projects/${slug}/feedback/${encodeURIComponent(item.id)}/attachments/${shot.id}`);
172
+ const name = shot.filename ? `${shot.id}-${basename(shot.filename)}` : `${shot.id}.png`;
173
+ const target = join(args.download, name);
174
+ await writeFile(target, bytes);
175
+ console.log(`Saved ${target}`);
176
+ }
177
+ }
178
+ })
179
+ });
180
+ const commentCommand = defineCommand({
181
+ meta: {
182
+ name: "comment",
183
+ description: "Append a comment to an item (e.g. debug findings)"
184
+ },
185
+ args: {
186
+ id: {
187
+ type: "positional",
188
+ description: "Item id",
189
+ required: true
190
+ },
191
+ text: {
192
+ type: "positional",
193
+ description: "Comment text",
194
+ required: true
195
+ },
196
+ ...projectDirArgs
197
+ },
198
+ run: reportErrors("feedback comment", async ({ args }) => {
199
+ const { slug } = resolveProject(args);
200
+ await apiRequest("POST", `/projects/${slug}/feedback/${encodeURIComponent(args.id)}/comments`, { body: args.text });
201
+ console.log("Comment added.");
202
+ })
203
+ });
204
+ const statusCommand = defineCommand({
205
+ meta: {
206
+ name: "status",
207
+ description: "Move an item to a new status (members only)"
208
+ },
209
+ args: {
210
+ id: {
211
+ type: "positional",
212
+ description: "Item id",
213
+ required: true
214
+ },
215
+ to: {
216
+ type: "positional",
217
+ description: STATUSES.join(" | "),
218
+ required: true
219
+ },
220
+ comment: {
221
+ type: "string",
222
+ description: "Optional note recorded with the change"
223
+ },
224
+ ...projectDirArgs
225
+ },
226
+ run: reportErrors("feedback status", async ({ args }) => {
227
+ const { slug } = resolveProject(args);
228
+ await apiRequest("POST", `/projects/${slug}/feedback/${encodeURIComponent(args.id)}/status`, {
229
+ status: args.to,
230
+ ...args.comment ? { comment: args.comment } : {}
231
+ });
232
+ console.log(`Marked ${args.id} as ${args.to}.`);
233
+ })
234
+ });
235
+ const mineCommand = defineCommand({
236
+ meta: {
237
+ name: "mine",
238
+ description: "Your own reports across all projects"
239
+ },
240
+ args: {
241
+ ...jsonArg,
242
+ project: {
243
+ type: "string",
244
+ description: "Only reports on this project slug"
245
+ }
246
+ },
247
+ run: reportErrors("feedback mine", async ({ args }) => {
248
+ const { items } = await apiRequest("GET", `/feedback/mine${args.project ? `?project=${encodeURIComponent(args.project)}` : ""}`);
249
+ if (args.json) {
250
+ console.log(JSON.stringify(items, null, 2));
251
+ return;
252
+ }
253
+ if (items.length === 0) {
254
+ console.log("You haven't filed any reports.");
255
+ return;
256
+ }
257
+ for (const item of items) console.log(`${item.id} ${item.status} ${item.kind} [${item.project_slug}] ${firstLine(item.body)}`);
258
+ })
259
+ });
260
+ const reportCommand = defineCommand({
261
+ meta: {
262
+ name: "report",
263
+ description: "File a report. Default target is the platform inbox (canopy itself); use --project for a specific project"
264
+ },
265
+ args: {
266
+ text: {
267
+ type: "positional",
268
+ description: "What happened / what you suggest",
269
+ required: true
270
+ },
271
+ kind: {
272
+ type: "string",
273
+ description: "bug | feedback (default: bug)"
274
+ },
275
+ project: {
276
+ type: "string",
277
+ description: "Project slug to file against (default: the platform inbox)"
278
+ },
279
+ command: {
280
+ type: "string",
281
+ description: "The canopy command that failed (goes in context)"
282
+ },
283
+ error: {
284
+ type: "string",
285
+ description: "The error output you saw (goes in context)"
286
+ },
287
+ attach: {
288
+ type: "string",
289
+ description: "Screenshot to attach (PNG/JPEG/WebP/GIF); repeat for several"
290
+ },
291
+ caption: {
292
+ type: "string",
293
+ description: "One line describing the screenshot(s)"
294
+ },
295
+ status: {
296
+ type: "string",
297
+ description: `Initial status, project members only: ${STATUSES.join(" | ")}`
298
+ }
299
+ },
300
+ run: reportErrors("feedback report", async ({ args }) => {
301
+ const kind = args.kind ?? "bug";
302
+ if (kind !== "bug" && kind !== "feedback") {
303
+ console.error(`--kind must be bug or feedback (got "${kind}")`);
304
+ process.exit(1);
305
+ }
306
+ const context = {
307
+ cli_version: version,
308
+ site: siteConfig().profileId ?? siteConfig().apiUrl,
309
+ platform: `${process.platform} node ${process.versions.node}`
310
+ };
311
+ const workingOn = tryReadCanopyJson(".")?.slug;
312
+ if (workingOn) context["working_on"] = workingOn;
313
+ if (args.command) context["command"] = args.command;
314
+ if (args.error) context["error"] = args.error;
315
+ const paths = attachedPaths(args.attach);
316
+ for (const path of paths) await readFile(path);
317
+ const { item } = await apiRequest("POST", "/feedback", {
318
+ kind,
319
+ body: args.text,
320
+ ...args.project ? { project: args.project } : {},
321
+ ...args.status ? { status: args.status } : {},
322
+ context
323
+ });
324
+ console.log(`Filed ${kind} ${item.id} on ${item.project} as ${item.status}.`);
325
+ if (paths.length > 0) await attachFiles(item.project, item.id, paths, args.caption);
326
+ console.log(`Follow it with \`canopy feedback mine\` or on the dashboard's Reports page.`);
327
+ })
328
+ });
329
+ const feedbackCommand = defineCommand({
330
+ meta: {
331
+ name: "feedback",
332
+ description: "Work a project's feedback inbox, or file a report (see docs: platform-feedback)"
333
+ },
334
+ subCommands: {
335
+ list: listCommand,
336
+ show: showCommand,
337
+ comment: commentCommand,
338
+ attach: defineCommand({
339
+ meta: {
340
+ name: "attach",
341
+ description: "Attach screenshots to an existing item (e.g. proof that a fix works)"
342
+ },
343
+ args: {
344
+ id: {
345
+ type: "positional",
346
+ description: "Item id",
347
+ required: true
348
+ },
349
+ file: {
350
+ type: "positional",
351
+ description: "Image file (repeat --attach for more)",
352
+ required: true
353
+ },
354
+ attach: {
355
+ type: "string",
356
+ description: "Another image file; repeat for several"
357
+ },
358
+ caption: {
359
+ type: "string",
360
+ description: "One line describing the screenshot(s)"
361
+ },
362
+ comment: {
363
+ type: "string",
364
+ description: "Post this comment first and hang the screenshots off it"
365
+ },
366
+ ...projectDirArgs
367
+ },
368
+ run: reportErrors("feedback attach", async ({ args }) => {
369
+ const { slug } = resolveProject(args);
370
+ const paths = [args.file, ...attachedPaths(args.attach)];
371
+ for (const path of paths) await readFile(path);
372
+ let commentId;
373
+ if (args.comment) {
374
+ const { comment } = await apiRequest("POST", `/projects/${slug}/feedback/${encodeURIComponent(args.id)}/comments`, { body: args.comment });
375
+ commentId = comment.id;
376
+ console.log("Comment added.");
377
+ }
378
+ await attachFiles(slug, args.id, paths, args.caption, commentId);
379
+ })
380
+ }),
381
+ status: statusCommand,
382
+ mine: mineCommand,
383
+ report: reportCommand
384
+ }
385
+ });
386
+ //#endregion
387
+ export { feedbackCommand };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-CPXtNnfc.mjs";
2
+ import { t as version } from "./package-27IYBxoU.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,29 @@ const main = defineCommand({
60
60
  description: "canopy — create and deploy vibecoded projects"
61
61
  },
62
62
  subCommands: {
63
- login: () => import("./login-C1x1l3qg.mjs").then((m) => m.loginCommand),
64
- whoami: () => import("./login-C1x1l3qg.mjs").then((m) => m.whoamiCommand),
65
- logout: () => import("./login-C1x1l3qg.mjs").then((m) => m.logoutCommand),
66
- create: () => import("./projects-C9TBoiW7.mjs").then((m) => m.createCommand),
67
- init: () => import("./projects-C9TBoiW7.mjs").then((m) => m.initCommand),
68
- projects: () => import("./projects-C9TBoiW7.mjs").then((m) => m.projectsCommand),
69
- delete: () => import("./projects-C9TBoiW7.mjs").then((m) => m.deleteCommand),
70
- dev: () => import("./dev-D6Sy15Wv.mjs").then((m) => m.devCommand),
71
- deploy: () => import("./deploy-ClioT9ZC.mjs").then((m) => m.deployCommand),
72
- promote: () => import("./deploy-ClioT9ZC.mjs").then((m) => m.promoteCommand),
73
- staging: () => import("./staging-95xeXyZN.mjs").then((m) => m.stagingCommand),
74
- rollback: () => import("./deploy-ClioT9ZC.mjs").then((m) => m.rollbackCommand),
75
- download: () => import("./deploy-ClioT9ZC.mjs").then((m) => m.downloadCommand),
76
- token: () => import("./token-BjlqT2Xj.mjs").then((m) => m.tokenCommand),
77
- egress: () => import("./egress-BkkG9Jzs.mjs").then((m) => m.egressCommand),
78
- db: () => import("./db-D3RKaLVU.mjs").then((m) => m.dbCommand),
79
- usage: () => import("./usage-wpew0a2M.mjs").then((m) => m.usageCommand),
80
- open: () => import("./open-CLSh6oez.mjs").then((m) => m.openCommand),
81
- docs: () => import("./docs-DhAz6QWS.mjs").then((m) => m.docsCommand),
82
- skill: () => import("./skill-BCX4cUeP.mjs").then((m) => m.skillCommand),
83
- upgrade: () => import("./upgrade-jLu7jI0r.mjs").then((m) => m.upgradeCommand),
84
- logs: () => import("./logs-DuzM-_Rn.mjs").then((m) => m.logsCommand)
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),
66
+ create: () => import("./projects-C6JQjVqg.mjs").then((m) => m.createCommand),
67
+ init: () => import("./projects-C6JQjVqg.mjs").then((m) => m.initCommand),
68
+ projects: () => import("./projects-C6JQjVqg.mjs").then((m) => m.projectsCommand),
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),
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),
76
+ token: () => import("./token-D7zrIF8G.mjs").then((m) => m.tokenCommand),
77
+ egress: () => import("./egress-CN_axsAN.mjs").then((m) => m.egressCommand),
78
+ feedback: () => import("./feedback-B7QOyMau.mjs").then((m) => m.feedbackCommand),
79
+ db: () => import("./db-8Etjmzxh.mjs").then((m) => m.dbCommand),
80
+ usage: () => import("./usage-CX1Izj_l.mjs").then((m) => m.usageCommand),
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),
85
+ logs: () => import("./logs-DNiZquki.mjs").then((m) => m.logsCommand)
85
86
  }
86
87
  });
87
88
  if (process.argv[2] !== "upgrade") notifyOfUpdate(version);
@@ -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-CxfnHPI4.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";
@@ -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.6";
2
+ var version = "0.7.8";
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";
@@ -96,6 +96,8 @@ and never go stale.
96
96
  - Roll back: `canopy rollback`
97
97
  - Open the live site: `canopy open`
98
98
  - Manage data: `canopy db pull | snapshot | snapshots | restore | migrate`
99
+ - Work bug reports & feedback: `canopy feedback list | show | comment | status | report`
100
+ — see `canopy docs platform-feedback` for the triage and reporting loops
99
101
  - CI credentials: `canopy token create | list | revoke`
100
102
 
101
103
  ## Custom tables & migrations (devs; Drizzle recommended)
@@ -151,3 +153,8 @@ Non-interactive: `canopy create "My App" --template spa`. **When unsure between
151
153
  - **Deploys are public.** `canopy deploy` makes a version live. Confirm before
152
154
  deploying unless the user clearly asked you to.
153
155
  - Keep the CLI current: `canopy upgrade` (this also refreshes this skill).
156
+ - **A `canopy` command itself failing?** First rule out user error (re-read
157
+ `canopy docs troubleshooting` and the command's `--help`). If the platform
158
+ really misbehaved, check `canopy feedback mine` for a duplicate, then file it:
159
+ `canopy feedback report "<what happened>" --command "<the command>" --error "<output>"`
160
+ — it reaches the platform team directly (no `--project` needed).
@@ -1,5 +1,5 @@
1
- import { t as version } from "./package-CPXtNnfc.mjs";
2
- import { n as reportErrors } from "./ui-CpdtK7mt.mjs";
1
+ import { t as version } from "./package-27IYBxoU.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
@@ -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-BCX4cUeP.mjs");
115
+ const { skillInstalled } = await import("./skill-7K_P-5oN.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.6",
3
+ "version": "0.7.8",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -20,8 +20,8 @@
20
20
  "esbuild": "^0.28.1",
21
21
  "hono": "^4.8.0",
22
22
  "vite-server": "npm:vite@^7",
23
- "@mediamonks/canopy-data-plane": "0.0.2",
24
- "@mediamonks/canopy": "0.0.4"
23
+ "@mediamonks/canopy": "0.0.4",
24
+ "@mediamonks/canopy-data-plane": "0.0.2"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.1",
@@ -1,2 +0,0 @@
1
- import { t as devCommand } from "./dev-BbAVZcAR.mjs";
2
- export { devCommand };