@mediamonks/canopy-cli 0.7.22 → 0.7.24

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 (44) hide show
  1. package/dist/db-jmYaum8F.mjs +251 -0
  2. package/dist/{deploy-DCggsLD7.mjs → deploy-CYKiBnrg.mjs} +3 -3
  3. package/dist/dev-C5BmSJo1.mjs +2 -0
  4. package/dist/{dev-CNqx_53I.mjs → dev-DVUEJ80c.mjs} +6 -6
  5. package/dist/{dev-worker-Bij9OUMk.mjs → dev-worker-CWU6XFe_.mjs} +35 -1
  6. package/dist/docs/ci.md +8 -1
  7. package/dist/docs/cli-egress.md +2 -1
  8. package/dist/docs/cli-login.md +2 -0
  9. package/dist/docs/cli-logout.md +9 -6
  10. package/dist/docs/cli-token.md +4 -2
  11. package/dist/docs/index.json +7 -2
  12. package/dist/docs/platform-ai-usage.md +36 -10
  13. package/dist/docs/platform-client-access.md +14 -2
  14. package/dist/docs/platform-egress.md +5 -3
  15. package/dist/docs/platform-members-and-access.md +29 -6
  16. package/dist/docs/platform-project-data.md +1 -0
  17. package/dist/docs/platform-security-headers.md +53 -0
  18. package/dist/docs/platform-slack.md +3 -1
  19. package/dist/docs/sdk-ai.md +102 -11
  20. package/dist/docs/sdk-database.md +30 -0
  21. package/dist/docs/sdk-files.md +6 -0
  22. package/dist/{docs-c0CdeBxh.mjs → docs-BLWio_65.mjs} +3 -3
  23. package/dist/{egress-BhbQfjjV.mjs → egress-BjN-7vil.mjs} +6 -3
  24. package/dist/{feedback-QhD8k6Ph.mjs → feedback-Bnmi1Ukv.mjs} +3 -3
  25. package/dist/index.mjs +26 -26
  26. package/dist/{local-CsSP4Vpt.mjs → local-CNPBZ89U.mjs} +38 -9
  27. package/dist/{login-BrEDKrb3.mjs → login-COZ7Xcm0.mjs} +57 -6
  28. package/dist/{logs-Beqz--Df.mjs → logs-B_YoflCq.mjs} +2 -2
  29. package/dist/{migrations-Bg9FEGFh.mjs → migrations-Btzr9_q7.mjs} +2 -2
  30. package/dist/{open-D7f1wNn3.mjs → open-D2hH933K.mjs} +2 -2
  31. package/dist/{package-BuwV_mje.mjs → package-DTm0EzxR.mjs} +1 -1
  32. package/dist/{projects-1r5387ui.mjs → projects-CLs6MEF7.mjs} +2 -2
  33. package/dist/{schedule-DK_Cv6Xf.mjs → schedule-B8zA8WlG.mjs} +2 -2
  34. package/dist/{site-BFaWfSVu.mjs → site-DM2J10CB.mjs} +1 -1
  35. package/dist/{skill-BvoYCIKk.mjs → skill-Dt49jdUT.mjs} +2 -2
  36. package/dist/{src-BKKAEkbH.mjs → src-BK0Hiwsn.mjs} +95 -3
  37. package/dist/{staging-CdpV2YHB.mjs → staging-pZpdZuj_.mjs} +2 -2
  38. package/dist/{token-C8Bmzwfl.mjs → token-BKwGa10J.mjs} +8 -4
  39. package/dist/{ui--atbZ-TM.mjs → ui-CUNkis89.mjs} +1 -1
  40. package/dist/{upgrade-0lmdjvom.mjs → upgrade-Cf81feok.mjs} +2 -2
  41. package/dist/{usage-C1-cvS2-.mjs → usage-UmzU6sGp.mjs} +2 -2
  42. package/package.json +3 -3
  43. package/dist/db-TaSLpQZ5.mjs +0 -204
  44. package/dist/dev-D7v7WK9O.mjs +0 -2
@@ -61,11 +61,34 @@ Turning it off again is the same toggle.
61
61
  > **Note:** Collaborators-only gates the **live app**. Management access (settings, data, deploys) is always
62
62
  > members-only, regardless of this toggle.
63
63
 
64
- ## Platform admins and the Users page
65
-
66
- Platform admins get an extra **Users** page in the dashboard listing everyone
67
- on Canopy — name, email, and when they first signed in — with a one-click way
68
- to grant or remove admin. Accounts are created automatically on first
69
- OneLogin sign-in; there is nothing to provision.
64
+ ## Platform admins and the Admin page
65
+
66
+ Platform admins get an extra **Admin** page in the dashboard. Its **Users**
67
+ tab lists everyone on Canopy — name, email, when they first and last signed
68
+ in — with a one-click way to grant or remove admin and to sign someone out
69
+ everywhere. Its **Sessions** tab lists every live CLI sign-in on the site:
70
+ who, from which machine, and when it was last used, each revocable on the
71
+ spot. Accounts are created automatically on first OneLogin sign-in; there is
72
+ nothing to provision.
73
+
74
+ ## Your sign-ins, and signing out everywhere
75
+
76
+ A Canopy sign-in lives in two places: the browser session from OneLogin, and
77
+ the CLI tokens `canopy login` stores on each machine (one per site). Your
78
+ **Account** page — open the avatar menu in the dashboard header — lists the
79
+ machines you have signed in from, with when each token was last used, and lets
80
+ you revoke any of them. Revoking a token is what `canopy logout` does for the
81
+ machine it runs on.
82
+
83
+ **Sign out everywhere** on the same page ends every browser session and every
84
+ CLI sign-in of yours on that site at once, this one included. Reach for it
85
+ when a laptop goes missing or you signed in somewhere you shouldn't have; the
86
+ next request from any of those places meets the sign-in page. Deploy tokens
87
+ used by CI belong to their projects, not to you, and keep working — revoke
88
+ those from the project's Deploys tab if they were on the lost machine too.
89
+
90
+ Platform admins have the same button for any user on the **Admin** page, which
91
+ is how someone who leaves the company is signed out of Canopy before their
92
+ open sessions would have expired on their own.
70
93
 
71
94
  - [Client access](https://docs.canopy.website/platform/client-access) — Share work-in-progress with clients who don't have a Monks account.
@@ -60,6 +60,7 @@ canopy db pull # download a fresh SQL dump
60
60
  canopy db snapshot # store a dump server-side
61
61
  canopy db snapshots # list stored snapshots
62
62
  canopy db restore # replace project data with a snapshot
63
+ canopy db access # what the app's own pages may do with the database
63
64
  ```
64
65
 
65
66
  `canopy db pull` is also how you develop against realistic data: pull a dump,
@@ -0,0 +1,53 @@
1
+ ---
2
+ title: Browser security
3
+ description: The security headers the platform adds to your app's pages and files, what they block, and the two switches in Settings.
4
+ ---
5
+
6
+ Every project on Canopy shares one sign-in, so a script that manages to run
7
+ inside one app is a script running next to every other app a visitor can open.
8
+ To keep that from mattering, the platform adds a small set of browser security
9
+ headers to the pages and files it serves for you. You don't configure anything
10
+ to get them, and most projects never notice they're there.
11
+
12
+ ## What your pages get
13
+
14
+ - **A Content-Security-Policy.** Scripts may load from your own app and from
15
+ any `https://` address, so CDNs and hosted libraries keep working. Inline
16
+ `<script>` blocks that are part of your deployed HTML keep working too — the
17
+ platform reads them at serve time and allows exactly those. What's blocked:
18
+ inline event handlers such as `onclick="…"`, `javascript:` links, plugins
19
+ (`<object>`, `<embed>`), scripts loaded over plain `http://`, and inline
20
+ scripts that were _not_ in your deploy — which is what an injected script
21
+ usually is.
22
+ - **No embedding by other sites.** Your pages can only be shown in an iframe by
23
+ your own app.
24
+ - **A referrer policy and HTTPS pinning.** Other sites see your origin but not
25
+ your full URLs, and browsers remember to always use HTTPS.
26
+
27
+ ## The two switches
28
+
29
+ Both live on the project's **Settings → Security** page, and both take effect
30
+ on the next page load — no redeploy.
31
+
32
+ - **Content-Security-Policy** — on by default. Turn it off if your app depends
33
+ on something the policy blocks and you can't change it: an inline `onclick`,
34
+ a `javascript:` link, a library that writes `<script>` tags into the page at
35
+ runtime, or a backend route that renders HTML with its own inline scripts.
36
+ Check the browser console first — a blocked script shows up there with the
37
+ directive that stopped it.
38
+ - **Allow embedding in other sites** — off by default. Turn it on when another
39
+ site needs to show this app in an iframe.
40
+
41
+ Backend-rendered pages (a worker route that returns HTML) receive the framing
42
+ and referrer headers but no script policy, because the platform only hashes
43
+ inline scripts it knows came from a deploy.
44
+
45
+ ## Files you store
46
+
47
+ Files saved with `canopy.blob` are served from your app's address, with the
48
+ content type you gave them. Images, video, audio, fonts, plain text, CSV, JSON
49
+ and PDF display normally. SVGs display too, but sandboxed — fine in an
50
+ `<img>`, and harmless if someone opens the URL directly. Anything a browser
51
+ would treat as a page — HTML, XML, scripts, or files with an unknown type —
52
+ downloads instead of rendering. If you want to show user-uploaded HTML, render
53
+ it inside your own page rather than linking to the stored file.
@@ -56,7 +56,9 @@ own screen. Click **Install to workspace** and follow it through.
56
56
 
57
57
  If your workspace requires admin approval for new apps, the first attempt
58
58
  becomes an approval request instead. Once the admin approves (Slack DMs you
59
- about it), come back and click install again.
59
+ about it), either come back and click install again, or use the install
60
+ link Slack itself offers — both complete the same way. A Slack-side install
61
+ only works into the workspace the app already belongs to.
60
62
 
61
63
  The same button reappears whenever you change features in a way that needs
62
64
  new permissions — the card shows a clear warning when saved changes are
@@ -4,8 +4,8 @@ description: Metered LLM access through the project's budget-capped gateway key
4
4
  ---
5
5
 
6
6
  `canopy.ai` gives your project LLM access without any API keys in your code.
7
- Every request authenticates as the project and is metered against a hard
8
- per-project daily budget — spend, limits, and how prompts are handled by the
7
+ Every request authenticates as the project and is metered against hard
8
+ per-project budgets — spend, limits, and how prompts are handled by the
9
9
  model providers are covered in [AI access & usage](https://docs.canopy.website/platform/ai-usage).
10
10
 
11
11
  ## ai.chat(options)
@@ -32,16 +32,65 @@ res.usage; // { inputTokens: number, outputTokens: number }
32
32
  **`ChatOptions`:**
33
33
 
34
34
  - `messages` — required, non-empty array of `{ role, content }`. `role` is
35
- `"system" | "user" | "assistant"`; `content` is a non-empty string.
35
+ `"system" | "user" | "assistant"`; `content` is a non-empty string, or an
36
+ array of content parts to attach documents and images (below).
36
37
  - `model` — optional `"{provider}/{model}"` id. Omit it and the platform's
37
38
  default model answers. Ask for a model whose provider isn't enabled and the
38
39
  call fails with a message listing what _is_ available; `ai.endpoint()`
39
40
  returns the full curated list.
40
- - `temperature` — optional number between 0 and 2.
41
- - `maxTokens` — optional integer, at least 1.
41
+ - `temperature` — optional number between 0 and 2. Reasoning models (the
42
+ GPT-5 family) only accept their default temperature, so the platform drops
43
+ this setting for them rather than failing the call — it has effect on
44
+ Claude models.
45
+ - `maxTokens` — optional integer, at least 1. On reasoning models the budget
46
+ also covers the model's internal reasoning, so a very small value can
47
+ return an empty answer — leave headroom.
48
+ - `effort` — optional `"low" | "medium" | "high"`: how hard a reasoning model
49
+ should think. Lowered onto each provider's own dial; models without an
50
+ effort dial (Claude Haiku, for one) simply ignore it. Lower effort is
51
+ faster and cheaper, higher is more thorough.
52
+
53
+ **`ChatResponse`:** `{ message, model, usage: { inputTokens, outputTokens } }`.
54
+ `message.role` is `"assistant"` and `message.content` is always a plain
55
+ string, whatever went in.
56
+
57
+ ## Attaching documents and images
58
+
59
+ A message's `content` can be an array of parts instead of a string. The
60
+ easiest way to send a file is to upload it to [blob storage](https://docs.canopy.website/sdk/blob) once
61
+ and reference it by key — the platform fetches it server-side and picks the
62
+ best way to hand it to the model:
42
63
 
43
- **`ChatResponse`:** `{ message: ChatMessage, model: string, usage: { inputTokens, outputTokens } }`.
44
- `message.role` is `"assistant"`.
64
+ ```ts
65
+ await canopy.blob.put("briefs/q3.pdf", file); // once
66
+
67
+ const res = await canopy.ai.chat({
68
+ messages: [
69
+ {
70
+ role: "user",
71
+ content: [
72
+ { type: "text", text: "Summarize this briefing." },
73
+ { type: "blob", key: "briefs/q3.pdf" },
74
+ ],
75
+ },
76
+ ],
77
+ });
78
+ ```
79
+
80
+ The part types:
81
+
82
+ - `{ type: "text", text }` — plain text.
83
+ - `{ type: "blob", key, mediaType? }` — a file already in the project's blob
84
+ storage, by key. `mediaType` overrides the stored content type if needed.
85
+ **Prefer this** for anything you'd otherwise base64 by hand.
86
+ - `{ type: "file", data, mediaType, filename? }` — an inline file;
87
+ `data` is base64 (no `data:` prefix), `mediaType` e.g. `"application/pdf"`.
88
+ - `{ type: "image", data, mediaType }` — an inline image, same encoding.
89
+
90
+ Pick a model that can read what you attach — document and image understanding
91
+ varies per model. The whole request (inline base64 and referenced blobs
92
+ together) must stay under the 8 MB request limit — see
93
+ [AI access & usage](https://docs.canopy.website/platform/ai-usage).
45
94
 
46
95
  ## ai.chatStream(options)
47
96
 
@@ -70,9 +119,10 @@ let an ecosystem client handle streaming for you.
70
119
  Returns the project's OpenAI-compatible endpoint info:
71
120
 
72
121
  ```ts
73
- const { baseUrl, models } = await canopy.ai.endpoint();
122
+ const { baseUrl, models, native } = await canopy.ai.endpoint();
74
123
  // baseUrl — an OpenAI-compatible base URL
75
124
  // models — the curated model list, "{provider}/{model}" ids; models[0] is the default
125
+ // native — provider-native base URLs: { anthropic, openai }
76
126
  ```
77
127
 
78
128
  Point any OpenAI-compatible client — the Vercel AI SDK, an OpenAI SDK, a
@@ -87,15 +137,56 @@ const openai = createOpenAI({ baseURL: baseUrl, apiKey: "unused" });
87
137
  const model = openai(models[0]);
88
138
  ```
89
139
 
140
+ ## Provider-native endpoints
141
+
142
+ The compat endpoint speaks OpenAI's chat-completions dialect to every model —
143
+ portable, but the lowest common denominator. When you want a provider's own
144
+ protocol — Anthropic prompt caching, PDFs with citations, or OpenAI's
145
+ Responses API — use the native base URLs from `endpoint()`. Requests pass
146
+ through to the provider unchanged; auth, the model allowlist, the daily
147
+ budget, and metering all still apply.
148
+
149
+ ```ts
150
+ import Anthropic from "@anthropic-ai/sdk";
151
+
152
+ const { native } = await canopy.ai.endpoint();
153
+ const anthropic = new Anthropic({ baseURL: native.anthropic, apiKey: "unused" });
154
+ const msg = await anthropic.messages.create({
155
+ model: "claude-haiku-4-5", // bare or "anthropic/…" — must be on the curated list
156
+ max_tokens: 1024,
157
+ messages: [{ role: "user", content: "Hello" }],
158
+ });
159
+ ```
160
+
161
+ A base URL is just a prefix — the client library appends the endpoint path
162
+ itself, based on which method you call. Both values point at the platform,
163
+ which forwards each path to the provider:
164
+
165
+ - `native.anthropic` — hand it to `@anthropic-ai/sdk` or `@ai-sdk/anthropic`.
166
+ Calling `anthropic.messages.create(...)` makes the SDK request
167
+ `POST {native.anthropic}/v1/messages` — the Messages API.
168
+ - `native.openai` — hand it to the `openai` package or `@ai-sdk/openai`. It
169
+ already ends in `/v1`, matching OpenAI's own base URL convention; the
170
+ method you call picks the endpoint: `client.responses.create(...)` requests
171
+ `POST {native.openai}/responses` (the Responses API) and
172
+ `client.chat.completions.create(...)` requests
173
+ `POST {native.openai}/chat/completions`. Both are supported.
174
+
175
+ Pick the surface by what you need: `ai.chat()` for plain text (and simple
176
+ attachments), the compat endpoint for portable multi-model apps and ecosystem
177
+ tooling, a native endpoint for provider-specific capabilities. Moving between
178
+ them never changes auth, budgets, or what the Usage tab shows.
179
+
90
180
  ## Errors
91
181
 
92
182
  Failed calls throw `CanopyError` with the HTTP `status` and the response body
93
183
  as the `message`. Two worth handling:
94
184
 
95
185
  - **Budget exhausted** — status `429`, body contains
96
- `"ai_budget_exceeded"`. The project hit its daily AI cap; AI calls resume
97
- the next day (or after a maintainer raises the budget). Everything else in
98
- your app keeps working.
186
+ `"ai_budget_exceeded"` and names which budget tripped: the daily limit
187
+ (calls resume the next day) or the monthly cap (calls resume next month —
188
+ or after a maintainer raises it). Everything else in your app keeps
189
+ working.
99
190
  - **Invalid request** — status `400`, body contains `"invalid_chat"` and a
100
191
  detail explaining which option was rejected.
101
192
 
@@ -122,5 +122,35 @@ per SDK instance, so calling it often is free.
122
122
  > `db.sql()` only when you genuinely need SQL (aggregates, joins, bulk updates), and branch on
123
123
  > `db.dialect()` whenever the syntax differs.
124
124
 
125
+ ## Who can call this from the browser
126
+
127
+ Everything above works from a page as well as from your worker — that is the
128
+ point of `canopy.db`: a form, a guestbook or an internal tool needs no backend
129
+ at all. But once a project _has_ a worker with tables the page must not touch,
130
+ you want a say. Two things give you that, both on the project's **Data** tab in
131
+ the dashboard (or `canopy db access` from the terminal):
132
+
133
+ - **Browser access** — one setting for what the app's own pages may do with the
134
+ database. **Read and write** is the default and how every project starts.
135
+ **Read only** lets pages read collections and run SQL that only reads, so
136
+ writes have to come from the worker. **Off** means pages cannot touch the
137
+ database at all. Your worker, the CLI and the dashboard's data explorer keep
138
+ full access whatever you pick.
139
+ - **`private_` names** — a collection or table whose name starts with
140
+ `private_` is only reachable from the worker, at every level. Pages calling
141
+ `collection("private_notes")` get a 403. Raw SQL cannot be checked table by
142
+ table, so while your database holds any `private_` name, `db.sql()` from a
143
+ page is refused too; use collections there, or run the SQL in the worker.
144
+
145
+ Everyone who opens the app gets the same rule — colleagues signed in with
146
+ OneLogin and, if you turned on [client access](https://docs.canopy.website/platform/client-access),
147
+ visitors with the password. Read only protects your data from being changed,
148
+ not from being seen: anyone who can open the app can read every table that is
149
+ not `private_`. To hide data, name it `private_` or turn browser access off
150
+ and serve it through a worker route.
151
+
152
+ A refused call throws a `CanopyError` with status 403 and a one-line
153
+ explanation that names the setting, so the fix is never a guess.
154
+
125
155
  Where the data lives, snapshots, and how to inspect it from the dashboard are
126
156
  covered in [Project data](https://docs.canopy.website/platform/project-data).
@@ -62,6 +62,12 @@ lifetime that isn't a whole number of seconds above zero is rejected. Keep the
62
62
  window as short as the job needs — anyone holding the URL can download the
63
63
  file until it expires.
64
64
 
65
+ What a URL does in the browser depends on the file's content type. Images,
66
+ video, audio, fonts, plain text, CSV, JSON and PDF display; SVG displays in
67
+ an `<img>` but is sandboxed when opened directly; anything a browser would
68
+ treat as a page — HTML, XML, scripts, unknown types — downloads instead of
69
+ rendering. See [Browser security](https://docs.canopy.website/platform/security-headers).
70
+
65
71
  ## Listing and deleting
66
72
 
67
73
  ```ts
@@ -1,7 +1,7 @@
1
- import { t as version } from "./package-BuwV_mje.mjs";
2
- import { n as siteConfig } from "./site-BFaWfSVu.mjs";
1
+ import { t as version } from "./package-DTm0EzxR.mjs";
2
+ import { n as siteConfig } from "./site-DM2J10CB.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
- import { r as reportErrors } from "./ui--atbZ-TM.mjs";
4
+ import { r as reportErrors } from "./ui-CUNkis89.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
- import { a as projectDirArgs, c as resolveProject } from "./site-BFaWfSVu.mjs";
2
- import { a as unwrap, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui--atbZ-TM.mjs";
1
+ import { a as projectDirArgs, c as resolveProject } from "./site-DM2J10CB.mjs";
2
+ import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  //#region src/egress.ts
@@ -152,7 +152,10 @@ const egressCommand = defineCommand({
152
152
  }
153
153
  }
154
154
  }
155
- const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { mode: wanted });
155
+ const updated = await withContext(apiRequest("PUT", `/projects/${slug}/egress`, { mode: wanted }), {
156
+ action: `switch ${slug} to ${wanted} egress mode`,
157
+ fixes: { owner_only: "Only the project owner can switch egress to open mode — ask them to run this command or flip it in the dashboard." }
158
+ });
156
159
  if (wanted === "open") {
157
160
  console.log(`Open egress mode is ON for ${slug} — running workers pick it up now.`);
158
161
  console.log(`Switch back anytime with \`canopy egress mode allowlist\`.`);
@@ -1,6 +1,6 @@
1
- import { t as version } from "./package-BuwV_mje.mjs";
2
- import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
3
- import { c as apiDownload, l as apiRequest, r as reportErrors, t as formatDateTime, u as apiUpload } from "./ui--atbZ-TM.mjs";
1
+ import { t as version } from "./package-DTm0EzxR.mjs";
2
+ import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
3
+ import { c as apiDownload, l as apiRequest, r as reportErrors, t as formatDateTime, u as apiUpload } from "./ui-CUNkis89.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { basename, extname, join } from "node:path";
6
6
  import { readFile, writeFile } from "node:fs/promises";
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-BuwV_mje.mjs";
3
- import { r as siteHeaderLine, t as setInvocationProfile } from "./site-BFaWfSVu.mjs";
2
+ import { t as version } from "./package-DTm0EzxR.mjs";
3
+ import { r as siteHeaderLine, t as setInvocationProfile } from "./site-DM2J10CB.mjs";
4
4
  import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
5
5
  import { defineCommand, runMain } from "citty";
6
6
  //#region src/index.ts
@@ -60,30 +60,30 @@ const main = defineCommand({
60
60
  description: "canopy — create and deploy vibecoded projects"
61
61
  },
62
62
  subCommands: {
63
- login: () => import("./login-BrEDKrb3.mjs").then((m) => m.loginCommand),
64
- whoami: () => import("./login-BrEDKrb3.mjs").then((m) => m.whoamiCommand),
65
- logout: () => import("./login-BrEDKrb3.mjs").then((m) => m.logoutCommand),
66
- create: () => import("./projects-1r5387ui.mjs").then((m) => m.createCommand),
67
- init: () => import("./projects-1r5387ui.mjs").then((m) => m.initCommand),
68
- projects: () => import("./projects-1r5387ui.mjs").then((m) => m.projectsCommand),
69
- delete: () => import("./projects-1r5387ui.mjs").then((m) => m.deleteCommand),
70
- dev: () => import("./dev-D7v7WK9O.mjs").then((m) => m.devCommand),
71
- deploy: () => import("./deploy-DCggsLD7.mjs").then((m) => m.deployCommand),
72
- promote: () => import("./deploy-DCggsLD7.mjs").then((m) => m.promoteCommand),
73
- staging: () => import("./staging-CdpV2YHB.mjs").then((m) => m.stagingCommand),
74
- rollback: () => import("./deploy-DCggsLD7.mjs").then((m) => m.rollbackCommand),
75
- download: () => import("./deploy-DCggsLD7.mjs").then((m) => m.downloadCommand),
76
- token: () => import("./token-C8Bmzwfl.mjs").then((m) => m.tokenCommand),
77
- egress: () => import("./egress-BhbQfjjV.mjs").then((m) => m.egressCommand),
78
- schedule: () => import("./schedule-DK_Cv6Xf.mjs").then((m) => m.scheduleCommand),
79
- feedback: () => import("./feedback-QhD8k6Ph.mjs").then((m) => m.feedbackCommand),
80
- db: () => import("./db-TaSLpQZ5.mjs").then((m) => m.dbCommand),
81
- usage: () => import("./usage-C1-cvS2-.mjs").then((m) => m.usageCommand),
82
- open: () => import("./open-D7f1wNn3.mjs").then((m) => m.openCommand),
83
- docs: () => import("./docs-c0CdeBxh.mjs").then((m) => m.docsCommand),
84
- skill: () => import("./skill-BvoYCIKk.mjs").then((m) => m.skillCommand),
85
- upgrade: () => import("./upgrade-0lmdjvom.mjs").then((m) => m.upgradeCommand),
86
- logs: () => import("./logs-Beqz--Df.mjs").then((m) => m.logsCommand)
63
+ login: () => import("./login-COZ7Xcm0.mjs").then((m) => m.loginCommand),
64
+ whoami: () => import("./login-COZ7Xcm0.mjs").then((m) => m.whoamiCommand),
65
+ logout: () => import("./login-COZ7Xcm0.mjs").then((m) => m.logoutCommand),
66
+ create: () => import("./projects-CLs6MEF7.mjs").then((m) => m.createCommand),
67
+ init: () => import("./projects-CLs6MEF7.mjs").then((m) => m.initCommand),
68
+ projects: () => import("./projects-CLs6MEF7.mjs").then((m) => m.projectsCommand),
69
+ delete: () => import("./projects-CLs6MEF7.mjs").then((m) => m.deleteCommand),
70
+ dev: () => import("./dev-C5BmSJo1.mjs").then((m) => m.devCommand),
71
+ deploy: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.deployCommand),
72
+ promote: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.promoteCommand),
73
+ staging: () => import("./staging-pZpdZuj_.mjs").then((m) => m.stagingCommand),
74
+ rollback: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.rollbackCommand),
75
+ download: () => import("./deploy-CYKiBnrg.mjs").then((m) => m.downloadCommand),
76
+ token: () => import("./token-BKwGa10J.mjs").then((m) => m.tokenCommand),
77
+ egress: () => import("./egress-BjN-7vil.mjs").then((m) => m.egressCommand),
78
+ schedule: () => import("./schedule-B8zA8WlG.mjs").then((m) => m.scheduleCommand),
79
+ feedback: () => import("./feedback-Bnmi1Ukv.mjs").then((m) => m.feedbackCommand),
80
+ db: () => import("./db-jmYaum8F.mjs").then((m) => m.dbCommand),
81
+ usage: () => import("./usage-UmzU6sGp.mjs").then((m) => m.usageCommand),
82
+ open: () => import("./open-D2hH933K.mjs").then((m) => m.openCommand),
83
+ docs: () => import("./docs-BLWio_65.mjs").then((m) => m.docsCommand),
84
+ skill: () => import("./skill-Dt49jdUT.mjs").then((m) => m.skillCommand),
85
+ upgrade: () => import("./upgrade-Cf81feok.mjs").then((m) => m.upgradeCommand),
86
+ logs: () => import("./logs-B_YoflCq.mjs").then((m) => m.logsCommand)
87
87
  }
88
88
  });
89
89
  if (process.argv[2] !== "upgrade") notifyOfUpdate(version);
@@ -1,6 +1,6 @@
1
- import { s as apiDataRequest } from "./ui--atbZ-TM.mjs";
2
- import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-BKKAEkbH.mjs";
3
- import { n as rewriteApiPath } from "./dev-CNqx_53I.mjs";
1
+ import { s as apiDataRequest } from "./ui-CUNkis89.mjs";
2
+ import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-BK0Hiwsn.mjs";
3
+ import { n as rewriteApiPath } from "./dev-DVUEJ80c.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, relative, sep } from "node:path";
@@ -15,19 +15,48 @@ import { Hono } from "hono";
15
15
  * - `fts: false` — search() is an ILIKE substring fallback: different
16
16
  * matches, no ranking (results order by created_at). Surfaced to clients
17
17
  * as `capabilities.fts` rather than papered over.
18
- * - patch() is a shallow jsonb merge (`||`): top-level keys replace rather
19
- * than deep-merge, and a null value stores null instead of removing the
20
- * key.
18
+ * - patch() has the SAME RFC 7386 semantics as SQLite's json_patch (nested
19
+ * objects merge, null removes a key), but Postgres has no built-in for it,
20
+ * so it goes through `public._canopy_merge_patch(jsonb, jsonb)` — a small
21
+ * recursive plpgsql function declared in the DDL below. It lives in
22
+ * `public` (not the per-project schema) so one CREATE OR REPLACE covers
23
+ * every project schema in the database, existing ones included.
21
24
  */
22
25
  const DOC_COLUMNS$1 = "id, data, created_at, updated_at";
23
- const DOC_STORE_DDL_PG = [`CREATE TABLE IF NOT EXISTS _canopy_docs (
26
+ const DOC_STORE_DDL_PG = [
27
+ `CREATE TABLE IF NOT EXISTS _canopy_docs (
24
28
  collection TEXT NOT NULL,
25
29
  id TEXT NOT NULL,
26
30
  data TEXT NOT NULL,
27
31
  created_at TEXT NOT NULL,
28
32
  updated_at TEXT NOT NULL,
29
33
  PRIMARY KEY (collection, id)
30
- )`, `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`];
34
+ )`,
35
+ `CREATE INDEX IF NOT EXISTS _canopy_docs_created ON _canopy_docs (collection, created_at)`,
36
+ `CREATE OR REPLACE FUNCTION public._canopy_merge_patch(target jsonb, patch jsonb)
37
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $fn$
38
+ BEGIN
39
+ IF jsonb_typeof(patch) IS DISTINCT FROM 'object' THEN
40
+ RETURN patch;
41
+ END IF;
42
+ IF jsonb_typeof(target) IS DISTINCT FROM 'object' THEN
43
+ target := '{}'::jsonb;
44
+ END IF;
45
+ RETURN COALESCE((
46
+ SELECT jsonb_object_agg(m.key, m.value) FROM (
47
+ SELECT t.key, t.value FROM jsonb_each(target) t WHERE NOT (patch ? t.key)
48
+ UNION ALL
49
+ SELECT p.key,
50
+ CASE WHEN jsonb_typeof(p.value) = 'object'
51
+ THEN public._canopy_merge_patch(target -> p.key, p.value)
52
+ ELSE p.value END
53
+ FROM jsonb_each(patch) p
54
+ WHERE jsonb_typeof(p.value) <> 'null'
55
+ ) m
56
+ ), '{}'::jsonb);
57
+ END
58
+ $fn$`
59
+ ];
31
60
  /** `data`'s JSON value at a `$.a.b` path param, as jsonb. */
32
61
  const jsonAt = (column, pathParam) => `(${column}::jsonb #> string_to_array(trim(leading '$.' from ${pathParam}::text), '.'))`;
33
62
  /**
@@ -124,7 +153,7 @@ const postgresDialect = {
124
153
  },
125
154
  patch(collection, id, patchJson, now) {
126
155
  return {
127
- sql: `UPDATE _canopy_docs SET data = (data::jsonb || $1::jsonb)::text, updated_at = $2
156
+ sql: `UPDATE _canopy_docs SET data = public._canopy_merge_patch(data::jsonb, $1::jsonb)::text, updated_at = $2
128
157
  WHERE collection = $3 AND id = $4
129
158
  RETURNING ${DOC_COLUMNS$1}`,
130
159
  params: [
@@ -1,6 +1,7 @@
1
- import { _ as writeConfigFile, f as clearAllProfileTokens, g as upsertProfileAuth, h as setActiveProfile, m as readConfigFile, n as siteConfig, p as listBakedProfiles, v as getBakedProfile, y as isProfileId } from "./site-BFaWfSVu.mjs";
2
- import { n as openBrowser, r as reportErrors } from "./ui--atbZ-TM.mjs";
1
+ import { _ as writeConfigFile, f as clearAllProfileTokens, g as upsertProfileAuth, h as setActiveProfile, m as readConfigFile, n as siteConfig, p as listBakedProfiles, v as getBakedProfile, y as isProfileId } from "./site-DM2J10CB.mjs";
2
+ import { n as openBrowser, r as reportErrors } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
+ import { hostname } from "node:os";
4
5
  import * as p from "@clack/prompts";
5
6
  import { createHash, randomBytes } from "node:crypto";
6
7
  import { createServer } from "node:http";
@@ -47,19 +48,66 @@ const whoamiCommand = defineCommand({
47
48
  }
48
49
  })
49
50
  });
51
+ /**
52
+ * Revoke one site's CLI token on the server (DELETE /control/v1/account/token —
53
+ * the token authenticates and names itself). Best-effort: an unreachable or
54
+ * already-expired token is still forgotten locally, the caller just hears
55
+ * about it. A 401 means the platform no longer knows the token — nothing left
56
+ * to revoke, so that counts as done.
57
+ */
58
+ async function revokeRemote(apiUrl, token) {
59
+ try {
60
+ const res = await fetch(`${apiUrl}/control/v1/account/token`, {
61
+ method: "DELETE",
62
+ headers: { authorization: `Bearer ${token}` }
63
+ });
64
+ if (res.ok || res.status === 401) return { ok: true };
65
+ return {
66
+ ok: false,
67
+ error: `${res.status} ${await res.text().catch(() => "")}`.trim()
68
+ };
69
+ } catch (err) {
70
+ return {
71
+ ok: false,
72
+ error: err instanceof Error ? err.message : String(err)
73
+ };
74
+ }
75
+ }
50
76
  const logoutCommand = defineCommand({
51
77
  meta: {
52
78
  name: "logout",
53
- description: "Sign out: forget all local CLI tokens"
79
+ description: "Sign out: revoke and forget all CLI tokens"
54
80
  },
55
- run: reportErrors("logout", async () => {
81
+ args: { local: {
82
+ type: "boolean",
83
+ default: false,
84
+ description: "Only forget the tokens on this machine; do not revoke them on the platform"
85
+ } },
86
+ run: reportErrors("logout", async ({ args }) => {
56
87
  const file = readConfigFile();
57
- if (!Object.values(file.profiles ?? {}).some((p) => p?.token)) {
88
+ const held = Object.entries(file.profiles ?? {}).filter(([, p]) => p?.token);
89
+ if (held.length === 0) {
58
90
  console.log("Not logged in.");
59
91
  return;
60
92
  }
93
+ const failed = [];
94
+ if (!args.local) for (const [id, prof] of held) {
95
+ const baked = isProfileId(id) ? getBakedProfile(id) : void 0;
96
+ if (!baked) continue;
97
+ const result = await revokeRemote(baked.apiUrl, prof.token);
98
+ if (!result.ok) failed.push(`${id} (${result.error})`);
99
+ }
61
100
  clearAllProfileTokens();
101
+ if (args.local) {
102
+ console.log("Logged out locally — tokens removed from ~/.canopy/config.json but still valid on the platform until they expire. Revoke them from the dashboard's Account page.");
103
+ return;
104
+ }
105
+ if (failed.length === 0) {
106
+ console.log("Logged out — all site tokens revoked and removed from ~/.canopy/config.json.");
107
+ return;
108
+ }
62
109
  console.log("Logged out — all site tokens removed from ~/.canopy/config.json.");
110
+ console.log(`Could not revoke on: ${failed.join(", ")}. Those tokens stay valid until they expire; revoke them from the dashboard's Account page.`);
63
111
  })
64
112
  });
65
113
  /** Wait for OneLogin to redirect back to the local loopback server. */
@@ -125,7 +173,10 @@ async function mintForProfile(id, idToken) {
125
173
  const cliTokenRes = await fetch(`${baked.apiUrl}/control/v1/auth/cli-token`, {
126
174
  method: "POST",
127
175
  headers: { "content-type": "application/json" },
128
- body: JSON.stringify({ id_token: idToken })
176
+ body: JSON.stringify({
177
+ id_token: idToken,
178
+ name: hostname()
179
+ })
129
180
  });
130
181
  if (!cliTokenRes.ok) return {
131
182
  ok: false,
@@ -1,5 +1,5 @@
1
- import { c as resolveProject, o as projectFlagArgs } from "./site-BFaWfSVu.mjs";
2
- import { d as withContext, l as apiRequest, r as reportErrors } from "./ui--atbZ-TM.mjs";
1
+ import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
2
+ import { d as withContext, l as apiRequest, r as reportErrors } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/logs.ts
5
5
  /**
@@ -1,5 +1,5 @@
1
- import { u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
2
- import { s as apiDataRequest } from "./ui--atbZ-TM.mjs";
1
+ import { u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
+ import { s as apiDataRequest } from "./ui-CUNkis89.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
- import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-BFaWfSVu.mjs";
2
- import { n as openBrowser, r as reportErrors } from "./ui--atbZ-TM.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, l as stagingUrl, s as projectUrl } from "./site-DM2J10CB.mjs";
2
+ import { n as openBrowser, r as reportErrors } from "./ui-CUNkis89.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.22";
2
+ var version = "0.7.24";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,5 +1,5 @@
1
- import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
2
- import { a as unwrap, i as requireTty, l as apiRequest, r as reportErrors } from "./ui--atbZ-TM.mjs";
1
+ import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
+ import { a as unwrap, i as requireTty, l as apiRequest, r as reportErrors } from "./ui-CUNkis89.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";