@mediamonks/canopy-cli 0.7.14 → 0.7.16

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.
@@ -10,12 +10,14 @@ description: Read the Canopy docs from the terminal — live from the platform,
10
10
  ```bash
11
11
  canopy docs # list all topics
12
12
  canopy docs <topic> # print one topic
13
+ canopy docs search <terms…> # keyword search across all docs
13
14
  canopy docs <topic> --refresh
14
15
  ```
15
16
 
16
17
  ## Arguments & flags
17
18
 
18
19
  - `[topic]` — positional: the topic slug to print. Omit it to list every topic with a one-line summary, grouped by section. Guides keep bare slugs (`deploy`, `staging`); other sections prefix theirs (`sdk-database`, `cli-deploy`, `platform-schedules`).
20
+ - `search <terms…>` — search every doc for the given terms instead of printing one. Case-insensitive; a doc matches only when every term appears somewhere in its title, summary, or body. Results come back best-first (title and summary hits outrank body mentions), each with the matching lines, capped at ten.
19
21
  - `--refresh` — re-fetch from the platform, bypassing the local cache. Default `false`.
20
22
 
21
23
  ## How it works
@@ -30,6 +32,8 @@ That last step is why `canopy docs` works with no network and no login: a fresh
30
32
 
31
33
  Asking for an unknown topic errors with the list of available slugs. Everything prints as plain Markdown to stdout, so it pipes cleanly into a pager or an agent's context.
32
34
 
35
+ Search runs entirely on the local corpus — the cache plus the bundled snapshot — so it's instant and works offline. It's a locator, not a reader: find the topic, then print it with `canopy docs <topic>` (which fetches fresh content when it can).
36
+
33
37
  ## Examples
34
38
 
35
39
  ```bash
@@ -38,6 +42,12 @@ canopy docs
38
42
 
39
43
  Lists every topic with its summary — the starting point when you're not sure which guide covers what you're doing.
40
44
 
45
+ ```bash
46
+ canopy docs search egress allowlist
47
+ ```
48
+
49
+ Finds the docs that mention both terms — useful when the summaries don't tell you which doc covers a detail, like a specific error code or environment variable.
50
+
41
51
  ```bash
42
52
  canopy docs deploy --refresh
43
53
  ```
@@ -14,6 +14,9 @@ canopy egress list
14
14
  canopy egress allow api.example.com
15
15
  canopy egress allow "*.googleapis.com"
16
16
  canopy egress remove api.example.com
17
+ canopy egress mode open --accept-risk
18
+ canopy egress mode allowlist
19
+ canopy egress log
17
20
  ```
18
21
 
19
22
  ## Arguments & flags
@@ -26,6 +29,12 @@ target directory, or `--project <slug>` to override — and honor the global
26
29
  - `allow <host>` — add a hostname (or `*.` subdomain wildcard; quote it so the
27
30
  shell doesn't expand the `*`).
28
31
  - `remove <host>` — remove an entry (as shown by `list`).
32
+ - `mode [allowlist|open]` — show or switch the
33
+ [egress mode](https://docs.canopy.website/platform/egress#open-mode). Switching to `open` prints a risk
34
+ summary and asks you to type the project slug; pass `--accept-risk` to
35
+ acknowledge it non-interactively. Switching back needs no ceremony.
36
+ - `log` — print the [outbound traffic log](https://docs.canopy.website/platform/egress#the-outbound-traffic-log):
37
+ hosts the worker actually fetched, with counts and last-seen times.
29
38
 
30
39
  ## How it works
31
40
 
@@ -39,23 +39,6 @@ Canopy authenticates through Monks' OneLogin. Run [`canopy login`](https://docs.
39
39
 
40
40
  ## Commands
41
41
 
42
- - [canopy login](https://docs.canopy.website/cli/login) — sign in through OneLogin in your browser
43
- - [canopy whoami](https://docs.canopy.website/cli/whoami) — show who you're signed in as, per site
44
- - [canopy logout](https://docs.canopy.website/cli/logout) — clear stored tokens
45
- - [canopy create](https://docs.canopy.website/cli/create) — scaffold and register a new project
46
- - [canopy init](https://docs.canopy.website/cli/init) — set up a folder for an existing project
47
- - [canopy projects](https://docs.canopy.website/cli/projects) — list your projects
48
- - [canopy delete](https://docs.canopy.website/cli/delete) — delete a project and its data
49
- - [canopy dev](https://docs.canopy.website/cli/dev) — run a project locally
50
- - [canopy deploy](https://docs.canopy.website/cli/deploy) — build and deploy a new version
51
- - [canopy rollback](https://docs.canopy.website/cli/rollback) — make a previous version live again
52
- - [canopy download](https://docs.canopy.website/cli/download) — download a deployed version's files
53
- - [canopy token](https://docs.canopy.website/cli/token) — manage deploy tokens for CI
54
- - [canopy db](https://docs.canopy.website/cli/db) — pull, snapshot, and restore project data
55
- - [canopy usage](https://docs.canopy.website/cli/usage) — AI spend and storage for a project
56
- - [canopy open](https://docs.canopy.website/cli/open) — open the live project in your browser
57
- - [canopy docs](https://docs.canopy.website/cli/docs) — read the Canopy guides, offline-capable
58
- - [canopy skill](https://docs.canopy.website/cli/skill) — install the Canopy agent skill into a repo
59
- - [canopy upgrade](https://docs.canopy.website/cli/upgrade) — update the CLI itself
60
-
61
- `canopy logs` (tail a project's worker logs) exists as a stub for a planned feature and currently exits with a pointer to the roadmap.
42
+ {/* Generated from this folder's meta.json + each page's frontmatter description — never hand-edit a list here. */}
43
+
44
+ <SectionIndex folder="/cli" />
@@ -0,0 +1,43 @@
1
+ ---
2
+ title: canopy schedule
3
+ description: List, add, and remove the project's cron schedules from the terminal.
4
+ ---
5
+
6
+ `canopy schedule` manages the project's named cron jobs — the same schedules
7
+ as the dashboard's **Settings → Schedules**, so a migration or an agent can
8
+ set up recurring jobs without leaving the terminal. See
9
+ [Schedules](https://docs.canopy.website/platform/schedules) for how schedules work and how your worker
10
+ handles them.
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ canopy schedule list
16
+ canopy schedule add refresh-feeds "0 5 * * *"
17
+ canopy schedule remove refresh-feeds
18
+ ```
19
+
20
+ ## Arguments & flags
21
+
22
+ All subcommands resolve the project the usual way — `canopy.json` in the
23
+ target directory, or `--project <slug>` to override — and honor the global
24
+ `--profile <site>` / `CANOPY_PROFILE`.
25
+
26
+ - `list` — print each schedule with its cron expression, enabled state, and
27
+ last run (including the error when the last run failed). `--json` prints
28
+ the raw JSON instead.
29
+ - `add <name> <cron>` — create a schedule, or update the cron of an existing
30
+ one. Names are lowercase letters, digits, and hyphens; the cron is a
31
+ standard five-field expression in **UTC** — quote it so the shell doesn't
32
+ expand the `*`s.
33
+ - `remove <name>` — delete a schedule (as shown by `list`).
34
+
35
+ ## How it works
36
+
37
+ Schedules live on the platform, per project — not in `canopy.json` — so
38
+ changing a cadence never needs a deploy. A project can have up to **5
39
+ schedules** with a shortest interval of one minute; names and cron
40
+ expressions are validated server-side. Your worker receives ticks in its
41
+ `scheduled` handler and switches on `event.name` — see
42
+ [Schedules](https://docs.canopy.website/platform/schedules). Enabling/disabling without deleting, and
43
+ firing a job manually to test it, remain dashboard-only for now.
@@ -112,12 +112,12 @@
112
112
  {
113
113
  "slug": "platform-schedules",
114
114
  "title": "Schedules",
115
- "summary": "Run named cron jobs in your project's backend — configured in the dashboard, so you can change the cadence without redeploying."
115
+ "summary": "Run named cron jobs in your project's backend — configured in the dashboard or the CLI, so you can change the cadence without redeploying."
116
116
  },
117
117
  {
118
118
  "slug": "platform-egress",
119
119
  "title": "Outbound requests",
120
- "summary": "Which external hosts your project's backend may fetch — a per-project allowlist you manage yourself."
120
+ "summary": "Which external hosts your project's backend may fetch — a per-project allowlist you manage yourself, or open mode for apps that fetch arbitrary URLs."
121
121
  },
122
122
  {
123
123
  "slug": "platform-ai-usage",
@@ -236,6 +236,11 @@
236
236
  "title": "canopy egress",
237
237
  "summary": "Manage which outbound hosts the project's backend worker may fetch."
238
238
  },
239
+ {
240
+ "slug": "cli-schedule",
241
+ "title": "canopy schedule",
242
+ "summary": "List, add, and remove the project's cron schedules from the terminal."
243
+ },
239
244
  {
240
245
  "slug": "cli-db",
241
246
  "title": "canopy db",
@@ -308,6 +313,11 @@
308
313
  "title": "AI",
309
314
  "summary": "Metered LLM access through the project's budget-capped gateway key — chat, streaming, and an OpenAI-compatible endpoint."
310
315
  },
316
+ {
317
+ "slug": "sdk-google",
318
+ "title": "Google",
319
+ "summary": "Call Google APIs as the project's connected account or as the visitor's own — the platform holds the OAuth."
320
+ },
311
321
  {
312
322
  "slug": "sdk-services",
313
323
  "title": "Services",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Outbound requests
3
- description: Which external hosts your project's backend may fetch — a per-project allowlist you manage yourself.
3
+ description: Which external hosts your project's backend may fetch — a per-project allowlist you manage yourself, or open mode for apps that fetch arbitrary URLs.
4
4
  ---
5
5
 
6
6
  Outbound `fetch` from a project's backend worker is **deny-by-default**: the
@@ -73,3 +73,54 @@ how recently each was hit. One click on **Allow** puts the host on the list
73
73
  and clears it from the blocked section. Hosts that keep a low profile drop
74
74
  off — the list remembers the 50 most recent — and allowing a host removes its
75
75
  blocked entry immediately.
76
+
77
+ ## Open mode
78
+
79
+ Some apps exist to fetch **arbitrary user-provided URLs** — link collectors,
80
+ feed readers, scrapers. For those, an allowlist is the wrong shape: the long
81
+ tail of domains never ends, and every new one is a denied request. Instead of
82
+ growing the list forever, switch the project to **open egress mode**:
83
+
84
+ ```bash
85
+ canopy egress mode open
86
+ ```
87
+
88
+ or in the dashboard: **Settings → Outbound hosts → Egress mode**. In open mode
89
+ the worker may fetch **any public hostname**. Three guardrails replace the
90
+ allowlist:
91
+
92
+ - **A built-in blocklist**, no exceptions: IP-literal URLs (v4 and v6),
93
+ `localhost`, private networks, and cloud metadata endpoints
94
+ (`metadata.google.internal` and friends) are always denied — in both modes.
95
+ - **An outbound rate limit** — generous (hundreds of fetches per minute; it
96
+ exists to stop runaway loops and hostile scraping, not to meter normal use).
97
+ Exceeding it answers 403 with `egress_rate_limited`, and the hits show up
98
+ under Recently blocked. If your app legitimately needs more, tell us —
99
+ the budget is meant to be raised when real use bumps into it.
100
+ - **The outbound traffic log** (below) records every host the worker talks to.
101
+
102
+ Because open mode means _users of your app can decide where it connects_,
103
+ enabling it is a deliberate act: the dashboard asks you to type the project
104
+ slug under a plain-language risk summary, and the CLI wants an interactive
105
+ confirmation or the explicit `--accept-risk` flag. Extra care applies if the
106
+ project has [client access](https://docs.canopy.website/platform/client-access) enabled — then untrusted
107
+ visitors drive the fetches.
108
+
109
+ Switching back is one command (`canopy egress mode allowlist`), and the
110
+ dashboard offers to **seed the allowlist from the observed hosts** so
111
+ tightening up later doesn't break the app.
112
+
113
+ ## The outbound traffic log
114
+
115
+ In both modes, the platform keeps a per-project log of the hosts the worker
116
+ **actually fetched** — hostnames only, never paths, query strings, headers, or
117
+ bodies. Per host it tracks a fetch count and first/last seen. See it under
118
+ **Outbound traffic** in Settings → Outbound hosts, or:
119
+
120
+ ```bash
121
+ canopy egress log
122
+ ```
123
+
124
+ In allowlist mode it doubles as a shortcut: any observed host can be promoted
125
+ to the allowlist with one click. The log keeps up to 1000 distinct hosts per
126
+ project; beyond that the least-recently-seen entry makes room.
@@ -9,7 +9,8 @@ own account**.
9
9
 
10
10
  Either way there is no OAuth setup to do. No Cloud project, no consent screen,
11
11
  no client ID or secret, no redirect URLs, no tokens to store or refresh. Canopy
12
- holds all of that.
12
+ holds all of that. This page covers the concepts and permissions; the calling
13
+ surface is documented in [the SDK reference](https://docs.canopy.website/sdk/google).
13
14
 
14
15
  ## The project's own account
15
16
 
@@ -1,12 +1,13 @@
1
1
  ---
2
2
  title: Schedules
3
- description: Run named cron jobs in your project's backend — configured in the dashboard, so you can change the cadence without redeploying.
3
+ description: Run named cron jobs in your project's backend — configured in the dashboard or the CLI, so you can change the cadence without redeploying.
4
4
  ---
5
5
 
6
6
  Schedules let a project do things on a clock: send a daily digest, refresh a
7
- cache every hour, tick a counter every minute. You define **named jobs** in
8
- the dashboard, and the platform wakes your project's backend at the right
9
- times — your code only decides what each job does.
7
+ cache every hour, tick a counter every minute. You define **named jobs** —
8
+ in the dashboard or with [`canopy schedule`](https://docs.canopy.website/cli/schedule) — and the
9
+ platform wakes your project's backend at the right times; your code only
10
+ decides what each job does.
10
11
 
11
12
  > **Note:** Schedules need a project with a custom backend. Per-project backends run on the **main** site and
12
13
  > the **GCP pilot** site. On sites without backend support the Schedules section doesn't appear in
@@ -28,6 +29,18 @@ you can see at a glance whether a job is healthy.
28
29
  You can also trigger a schedule manually from the dashboard, which is the
29
30
  quickest way to test a handler without waiting for the clock.
30
31
 
32
+ The same jobs are manageable from the terminal — handy when a migration or a
33
+ coding agent sets up a project end to end:
34
+
35
+ ```bash
36
+ canopy schedule list
37
+ canopy schedule add refresh-feeds "0 5 * * *"
38
+ canopy schedule remove refresh-feeds
39
+ ```
40
+
41
+ See [canopy schedule](https://docs.canopy.website/cli/schedule) for the details. Pausing a job and
42
+ firing one manually are dashboard-only.
43
+
31
44
  ## Handling schedules in your code
32
45
 
33
46
  Your project's worker exports a `scheduled` handler next to its normal
@@ -0,0 +1,139 @@
1
+ ---
2
+ title: Google
3
+ description: Call Google APIs as the project's connected account or as the visitor's own — the platform holds the OAuth.
4
+ ---
5
+
6
+ `canopy.google` calls Google APIs through the platform — no client IDs,
7
+ secrets, redirect URLs, or tokens in project code. There are two grants,
8
+ chosen explicitly because they act as different accounts:
9
+
10
+ ```ts
11
+ canopy.google.project(); // the account the owner connected — backend only
12
+ canopy.google.user(); // the calling visitor's own account
13
+ ```
14
+
15
+ What each grant is for, who connects one, and every permission bundle is
16
+ covered in [Google](https://docs.canopy.website/platform/google) on the platform side — this page is the
17
+ calling surface. Both grants share the same three calls; the user grant adds
18
+ the connect flow.
19
+
20
+ ## grant.status()
21
+
22
+ Whether this grant is usable right now — check it before offering a feature:
23
+
24
+ ```ts
25
+ const status = await canopy.google.user().status();
26
+ if (!status.connected) {
27
+ // show a connect button
28
+ }
29
+ ```
30
+
31
+ | Field | Type | Meaning |
32
+ | ------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | `configured` | `boolean` | Whether this Canopy site has Google set up at all. |
34
+ | `connected` | `boolean` | Whether an account is connected on this grant. |
35
+ | `available` | `boolean?` | Whether this caller could use the grant at all — `false` for the project grant in a browser, or the user grant in a scheduled job. A fact to branch on, not an error. |
36
+ | `reason` | `string?` | Why the grant is unavailable, when it is. |
37
+ | `email` | `string?` | Which account acts when this grant is used. |
38
+ | `bundles` | `string[]?` | Permission bundles the grant can actually use. Google lets people approve a subset of what was asked, so branch on this, not on what you requested. |
39
+ | `requested` | `string[]?` | What was asked for. Anything here but missing from `bundles` needs a reconnect. |
40
+ | `catalog` | `GoogleBundle[]?` | Everything that could be connected — key, label, description, tier — for building your own connect prompt. |
41
+
42
+ ## grant.fetch(url, init?)
43
+
44
+ Call a Google API URL and get its JSON response back, typed with the generic
45
+ you provide:
46
+
47
+ ```ts
48
+ const sheet = await canopy.google
49
+ .project()
50
+ .fetch("https://sheets.googleapis.com/v4/spreadsheets/<id>/values/A1:C10");
51
+
52
+ const event = await canopy.google
53
+ .user()
54
+ .fetch<CalendarEvent>("https://www.googleapis.com/calendar/v3/calendars/primary/events", {
55
+ method: "POST",
56
+ body: { summary: "Sync", start, end },
57
+ });
58
+ ```
59
+
60
+ - `url` — a full Google API URL, straight from Google's own API reference.
61
+ - `init.method` — `GET` (default), `POST`, `PUT`, `PATCH`, or `DELETE`.
62
+ - `init.headers` — optional; only `content-type`, `accept`, and `accept-language` reach Google.
63
+ - `init.body` — objects are JSON-encoded; strings are sent as-is.
64
+
65
+ Which URLs work depends on the permission bundles the grant holds — a call
66
+ outside them (or a write on a reading-only bundle) is refused before it
67
+ reaches Google and throws `CanopyError`.
68
+
69
+ ## grant.raw(url, init?)
70
+
71
+ The same call, returning the raw `Response` — for endpoints that answer with
72
+ bytes rather than JSON, like a Drive file download or an exported document:
73
+
74
+ ```ts
75
+ const res = await canopy.google
76
+ .project()
77
+ .raw("https://www.googleapis.com/drive/v3/files/<id>?alt=media");
78
+ const pdf = await res.blob();
79
+ ```
80
+
81
+ ## Connecting a visitor (user grant only)
82
+
83
+ The project grant is connected once, by the owner, on the dashboard's
84
+ **Services** tab — there is no code involved. Visitors connect themselves,
85
+ from your pages:
86
+
87
+ ```ts
88
+ const google = canopy.google.user();
89
+
90
+ // On load: did we just come back from a connect trip?
91
+ const result = google.consumeConnectResult(); // toast this, if not null
92
+
93
+ const status = await google.status();
94
+ if (!status.connected) {
95
+ google.connect(["calendar-read"]); // full-page trip to Google, then back here
96
+ }
97
+ ```
98
+
99
+ ### user.connect(bundles, returnTo?)
100
+
101
+ Sends the browser to Google's consent screen — a full-page navigation, not a
102
+ fetch, so put the button somewhere without unsaved work. `bundles` is the list
103
+ of permission bundle keys to ask for (e.g. `"sheets-read"`, `"calendar"`,
104
+ `"gmail-send"` — the picker on the Services tab shows every key, and
105
+ `status().catalog` lists them at runtime). `returnTo` defaults to the current
106
+ page. Browser only — calling it in a worker throws.
107
+
108
+ ### user.connectUrl(bundles, returnTo?)
109
+
110
+ The URL `connect()` navigates to, when you want to render it yourself as a
111
+ plain link.
112
+
113
+ ### user.consumeConnectResult()
114
+
115
+ Reads the one-shot outcome the connect flow left in the URL and strips it, so
116
+ a refresh doesn't replay it. Returns `null` when this page load wasn't a
117
+ return trip:
118
+
119
+ ```ts
120
+ const result = google.consumeConnectResult();
121
+ // { ok: true, email: "…" } | { ok: false, reason: "cancelled" | … } | null
122
+ ```
123
+
124
+ Call it on load and show a message when it isn't null. After the trip,
125
+ `status()` is the reliable answer to "are they connected" — this only tells
126
+ you how the trip that just happened went.
127
+
128
+ ### user.disconnect()
129
+
130
+ Drops the visitor's connection and revokes it at Google, so access stops
131
+ immediately:
132
+
133
+ ```ts
134
+ await canopy.google.user().disconnect();
135
+ ```
136
+
137
+ > **Note:** The user grant also works in worker code that is handling that visitor's request — same account,
138
+ > same calls. It cannot work in a scheduled job, because then nobody is signed in; that is what the
139
+ > project grant is for.
package/dist/docs/sdk.md CHANGED
@@ -4,8 +4,8 @@ description: One typed client for auth, data, files, AI, and services — no key
4
4
  ---
5
5
 
6
6
  `@mediamonks/canopy` is a thin, typed client over the platform's project API.
7
- One `createCanopy()` call gives you five modules — `auth`, `db`, `blob`, `ai`,
8
- and `services` — and that is the entire surface. There are no API keys,
7
+ One `createCanopy()` call gives you six modules — `auth`, `db`, `blob`, `ai`,
8
+ `google`, and `services` — and that is the entire surface. There are no API keys,
9
9
  connection strings, or environment variables to manage: every call rides the
10
10
  session the platform already established before your code ran.
11
11
 
@@ -35,7 +35,7 @@ import { createCanopy } from "/_api/data/v1/sdk.js";
35
35
  import { createCanopy } from "@mediamonks/canopy";
36
36
 
37
37
  const canopy = createCanopy();
38
- // canopy.auth, canopy.db, canopy.blob, canopy.ai, canopy.services
38
+ // canopy.auth, canopy.db, canopy.blob, canopy.ai, canopy.google, canopy.services
39
39
  ```
40
40
 
41
41
  | Option | Type | Default | What it does |
@@ -119,8 +119,6 @@ environments:
119
119
 
120
120
  ## Modules
121
121
 
122
- - [Auth](https://docs.canopy.website/sdk/auth) — who is signed in, and ending sessions.
123
- - [Database](https://docs.canopy.website/sdk/database) — document collections, plus a raw SQL escape hatch.
124
- - [Files](https://docs.canopy.website/sdk/files) — private blob storage under the project's prefix.
125
- - [AI](https://docs.canopy.website/sdk/ai) — metered, budget-capped LLM access.
126
- - [Services](https://docs.canopy.website/sdk/services) — curated internal data services.
122
+ {/* Generated from this folder's meta.json + each page's frontmatter description — never hand-edit a list here. */}
123
+
124
+ <SectionIndex folder="/sdk" />
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CuyFMXam.mjs";
1
+ import { t as version } from "./package-BGDIsRxe.mjs";
2
2
  import { n as siteConfig } from "./site-BUlQR117.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
4
  import { n as reportErrors } from "./ui-B1Px5y7q.mjs";
@@ -14,6 +14,10 @@ import { fileURLToPath } from "node:url";
14
14
  * never goes stale, with a local cache and a bundled snapshot as offline
15
15
  * fallbacks. The listing is grouped per docs-site section.
16
16
  *
17
+ * `canopy docs search <terms…>` greps the locally available corpus (cache +
18
+ * bundled snapshot) — the corpus is small enough (~60 docs) that keyword
19
+ * matching beats any index, and it works fully offline.
20
+ *
17
21
  * The platform requires a login for docs (internal platform), so fetches carry
18
22
  * the CLI token when there is one; without it we simply fall back to the
19
23
  * bundled snapshot, so `canopy docs` still works before sign-in.
@@ -140,6 +144,75 @@ function bodyFromFallback(slug) {
140
144
  }
141
145
  }
142
146
  }
147
+ const SEARCH_LIMIT = 10;
148
+ const LINE_LIMIT = 2;
149
+ const LINE_WIDTH = 120;
150
+ /** A matching body line, trimmed and windowed around the first term hit. */
151
+ function excerpt(line, matchIndex) {
152
+ const clean = line.replace(/\s+/g, " ").trim();
153
+ if (clean.length <= LINE_WIDTH) return clean;
154
+ const start = Math.max(0, Math.min(matchIndex - Math.floor(LINE_WIDTH / 3), clean.length - LINE_WIDTH));
155
+ const slice = clean.slice(start, start + LINE_WIDTH);
156
+ return `${start > 0 ? "…" : ""}${slice}${start + LINE_WIDTH < clean.length ? "…" : ""}`;
157
+ }
158
+ /**
159
+ * Keyword search over the docs corpus: case-insensitive, every term must match
160
+ * somewhere in a doc (title, summary, or body). Title and summary hits weigh
161
+ * more than body occurrences; results come back best-first.
162
+ */
163
+ function searchDocs(entries, query) {
164
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
165
+ if (terms.length === 0) return [];
166
+ const hits = [];
167
+ for (const entry of entries) {
168
+ const title = entry.title.toLowerCase();
169
+ const summary = entry.summary.toLowerCase();
170
+ const body = entry.body.toLowerCase();
171
+ let score = 0;
172
+ let matchesAll = true;
173
+ for (const term of terms) {
174
+ let termScore = 0;
175
+ if (title.includes(term)) termScore += 10;
176
+ if (summary.includes(term)) termScore += 5;
177
+ let count = 0;
178
+ for (let i = body.indexOf(term); i !== -1 && count < 20; i = body.indexOf(term, i + term.length)) count++;
179
+ termScore += count;
180
+ if (termScore === 0) {
181
+ matchesAll = false;
182
+ break;
183
+ }
184
+ score += termScore;
185
+ }
186
+ if (!matchesAll) continue;
187
+ const lines = [];
188
+ for (const line of entry.body.split("\n")) {
189
+ const lower = line.toLowerCase();
190
+ const at = terms.map((t) => lower.indexOf(t)).find((i) => i !== -1);
191
+ if (at !== void 0 && line.trim()) {
192
+ lines.push(excerpt(line, at));
193
+ if (lines.length >= LINE_LIMIT) break;
194
+ }
195
+ }
196
+ hits.push({
197
+ slug: entry.slug,
198
+ title: entry.title,
199
+ score,
200
+ lines
201
+ });
202
+ }
203
+ return hits.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
204
+ }
205
+ /**
206
+ * Every topic paired with its locally available body (cache first, bundled
207
+ * snapshot second). A topic with no local body still gets searched by title
208
+ * and summary — search never hits the network per doc.
209
+ */
210
+ function searchEntries(index) {
211
+ return index.topics.map((t) => ({
212
+ ...t,
213
+ body: bodyFromFallback(t.slug) ?? ""
214
+ }));
215
+ }
143
216
  async function listIndex(refresh) {
144
217
  const { apiUrl } = siteConfig();
145
218
  if (refresh || !cacheFresh()) {
@@ -174,7 +247,7 @@ const docsCommand = defineCommand({
174
247
  topic: {
175
248
  type: "positional",
176
249
  required: false,
177
- description: "Topic to print (omit to list)"
250
+ description: "Topic to print (omit to list), or `search <terms…>`"
178
251
  },
179
252
  refresh: {
180
253
  type: "boolean",
@@ -187,6 +260,23 @@ const docsCommand = defineCommand({
187
260
  const topic = args.topic;
188
261
  const latest = latestKnownVersion();
189
262
  if (latest && compareSemver(version, latest) < 0) console.error(`Note: these docs describe CLI ${latest}; you have ${version} — commands or flags they mention may not exist here. Run \`canopy upgrade\`.\n`);
263
+ if (topic === "search") {
264
+ const query = args._.slice(1).join(" ").trim();
265
+ if (!query) throw new Error("Usage: canopy docs search <terms…>");
266
+ const hits = searchDocs(searchEntries(await listIndex(refresh)), query);
267
+ if (hits.length === 0) {
268
+ console.log(`No docs match "${query}". Run \`canopy docs\` to list all topics.`);
269
+ return;
270
+ }
271
+ const shown = hits.slice(0, SEARCH_LIMIT);
272
+ console.log(`${hits.length} doc${hits.length === 1 ? "" : "s"} match "${query}" — run \`canopy docs <topic>\` to read one:\n`);
273
+ for (const hit of shown) {
274
+ console.log(`${hit.slug} — ${hit.title}`);
275
+ for (const line of hit.lines) console.log(` ${line}`);
276
+ }
277
+ if (hits.length > shown.length) console.log(`\n…and ${hits.length - shown.length} more — add terms to narrow it down.`);
278
+ return;
279
+ }
190
280
  if (!topic) {
191
281
  const index = await listIndex(refresh);
192
282
  if (index.topics.length === 0) throw new Error("No docs available — check your connection, or reinstall the CLI.");
@@ -0,0 +1,184 @@
1
+ import { a as projectDirArgs, c as resolveProject } from "./site-BUlQR117.mjs";
2
+ import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-B1Px5y7q.mjs";
3
+ import { defineCommand } from "citty";
4
+ import * as p from "@clack/prompts";
5
+ //#region src/egress.ts
6
+ /**
7
+ * canopy egress — manage the project's outbound-host policy: the allowlist
8
+ * (docs/16-egress-allowlist.md) and the egress mode (docs/25-egress-modes.md).
9
+ * In allowlist mode, worker fetches to hosts outside the list are denied
10
+ * (`egress_denied`) on targets that enforce egress control; open mode allows
11
+ * any public hostname behind a built-in blocklist and an outbound rate limit.
12
+ * Policy lives on the platform, not in canopy.json, so a repo file can never
13
+ * widen egress silently on deploy.
14
+ */
15
+ function printList({ mode, hosts, denied, enforced }, slug) {
16
+ if (mode === "open") console.log(`${slug} is in OPEN egress mode — the worker may fetch any public host (the allowlist below is kept but not enforced; \`canopy egress mode allowlist\` restores it).`);
17
+ if (hosts.length === 0) console.log(`No extra egress hosts for ${slug} — the worker can reach platform hosts only.`);
18
+ else for (const host of hosts) console.log(host);
19
+ if (denied.length > 0) {
20
+ console.log(`\nRecently blocked (allow with \`canopy egress allow <host>\`):`);
21
+ for (const d of denied) {
22
+ const why = d.reason === "rate_limited" ? "rate limited" : "blocked";
23
+ console.log(` ${d.host} ×${d.count} ${why}, last ${d.last_seen}`);
24
+ }
25
+ }
26
+ if (!enforced) console.error("(note: this site's target does not enforce egress yet — the policy is recorded for when it does)");
27
+ }
28
+ const listCommand = defineCommand({
29
+ meta: {
30
+ name: "list",
31
+ description: "List the project's allowed outbound hosts"
32
+ },
33
+ args: { ...projectDirArgs },
34
+ run: reportErrors("egress list", async ({ args }) => {
35
+ const { slug } = resolveProject(args);
36
+ printList(await apiRequest("GET", `/projects/${slug}/egress`), slug);
37
+ })
38
+ });
39
+ const allowCommand = defineCommand({
40
+ meta: {
41
+ name: "allow",
42
+ description: "Allow the worker to fetch a host (e.g. api.example.com or *.googleapis.com)"
43
+ },
44
+ args: {
45
+ host: {
46
+ type: "positional",
47
+ description: "Hostname to allow",
48
+ required: true
49
+ },
50
+ ...projectDirArgs
51
+ },
52
+ run: reportErrors("egress allow", async ({ args }) => {
53
+ const { slug } = resolveProject(args);
54
+ const current = await apiRequest("GET", `/projects/${slug}/egress`);
55
+ const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { hosts: [...current.hosts, args.host] });
56
+ console.log(`Allowed ${args.host} for ${slug} — running workers pick it up now.\n`);
57
+ printList(updated, slug);
58
+ })
59
+ });
60
+ const removeCommand = defineCommand({
61
+ meta: {
62
+ name: "remove",
63
+ description: "Remove a host from the allowlist"
64
+ },
65
+ args: {
66
+ host: {
67
+ type: "positional",
68
+ description: "Hostname to remove",
69
+ required: true
70
+ },
71
+ ...projectDirArgs
72
+ },
73
+ run: reportErrors("egress remove", async ({ args }) => {
74
+ const { slug } = resolveProject(args);
75
+ const current = await apiRequest("GET", `/projects/${slug}/egress`);
76
+ const wanted = args.host.trim().toLowerCase();
77
+ if (!current.hosts.includes(wanted)) {
78
+ console.error(`${wanted} is not on the allowlist for ${slug} (see \`canopy egress list\`).`);
79
+ process.exit(1);
80
+ }
81
+ const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { hosts: current.hosts.filter((h) => h !== wanted) });
82
+ console.log(`Removed ${wanted} from ${slug} — running workers stop reaching it now.\n`);
83
+ printList(updated, slug);
84
+ })
85
+ });
86
+ /** The plain-language risk summary shown before open mode is enabled (docs/25 §2). */
87
+ function openModeRiskSummary(slug, clientAccess) {
88
+ const lines = [
89
+ `Open egress mode lets ${slug}'s worker contact ANY public host:`,
90
+ "",
91
+ " - If the URLs it fetches come from user input, users control where it connects.",
92
+ " - Abusive outbound traffic can get the project rate-limited or paused.",
93
+ " - A built-in blocklist (private IPs, localhost, cloud metadata) and an",
94
+ " outbound rate limit still apply, and every visited host is logged",
95
+ " (`canopy egress log`)."
96
+ ];
97
+ if (clientAccess) lines.push("", " ! This project has client access enabled — untrusted visitors can drive", " what the worker fetches.");
98
+ lines.push("", "It's the right choice for apps that fetch arbitrary user-provided URLs", "(link collectors, feed readers, scrapers). Everything else is better served", "by the allowlist.");
99
+ return lines.join("\n");
100
+ }
101
+ const egressCommand = defineCommand({
102
+ meta: {
103
+ name: "egress",
104
+ description: "Manage which outbound hosts the project's worker may fetch"
105
+ },
106
+ subCommands: {
107
+ list: listCommand,
108
+ allow: allowCommand,
109
+ remove: removeCommand,
110
+ mode: defineCommand({
111
+ meta: {
112
+ name: "mode",
113
+ description: "Show or switch the egress mode (allowlist | open)"
114
+ },
115
+ args: {
116
+ mode: {
117
+ type: "positional",
118
+ description: "allowlist | open (omit to show)",
119
+ required: false
120
+ },
121
+ "accept-risk": {
122
+ type: "boolean",
123
+ description: "Acknowledge the open-mode risk summary without an interactive prompt"
124
+ },
125
+ ...projectDirArgs
126
+ },
127
+ run: reportErrors("egress mode", async ({ args }) => {
128
+ const { slug } = resolveProject(args);
129
+ const current = await apiRequest("GET", `/projects/${slug}/egress`);
130
+ const wanted = args.mode?.trim().toLowerCase();
131
+ if (!wanted) {
132
+ console.log(current.mode);
133
+ return;
134
+ }
135
+ if (wanted !== "allowlist" && wanted !== "open") {
136
+ console.error(`unknown mode "${wanted}" — expected allowlist or open.`);
137
+ process.exit(1);
138
+ }
139
+ if (wanted === current.mode) {
140
+ console.log(`${slug} is already in ${wanted} mode.`);
141
+ return;
142
+ }
143
+ if (wanted === "open") {
144
+ const access = await apiRequest("GET", `/projects/${slug}/client-password`).catch(() => ({ enabled: false }));
145
+ console.log(openModeRiskSummary(slug, access.enabled));
146
+ console.log("");
147
+ if (!args["accept-risk"]) {
148
+ requireTty("pass --accept-risk to enable open mode without confirmation.");
149
+ if (unwrap(await p.text({ message: `Type the project slug ("${slug}") to enable open mode.` })).trim() !== slug) {
150
+ p.cancel("Aborted — egress mode unchanged.");
151
+ return;
152
+ }
153
+ }
154
+ }
155
+ const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { mode: wanted });
156
+ if (wanted === "open") {
157
+ console.log(`Open egress mode is ON for ${slug} — running workers pick it up now.`);
158
+ console.log(`Switch back anytime with \`canopy egress mode allowlist\`.`);
159
+ } else {
160
+ console.log(`Allowlist mode restored for ${slug} — only listed hosts are reachable.\n`);
161
+ printList(updated, slug);
162
+ }
163
+ })
164
+ }),
165
+ log: defineCommand({
166
+ meta: {
167
+ name: "log",
168
+ description: "Show the outbound traffic log — hosts the worker actually fetched"
169
+ },
170
+ args: { ...projectDirArgs },
171
+ run: reportErrors("egress log", async ({ args }) => {
172
+ const { slug } = resolveProject(args);
173
+ const { visits } = await apiRequest("GET", `/projects/${slug}/egress/log`);
174
+ if (visits.length === 0) {
175
+ console.log(`No outbound traffic recorded for ${slug} yet.`);
176
+ return;
177
+ }
178
+ for (const v of visits) console.log(`${v.host} ×${v.count} last ${v.last_seen}`);
179
+ })
180
+ })
181
+ }
182
+ });
183
+ //#endregion
184
+ export { egressCommand };
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CuyFMXam.mjs";
1
+ import { t as version } from "./package-BGDIsRxe.mjs";
2
2
  import { a as projectDirArgs, c as resolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
3
3
  import { c as apiRequest, l as apiUpload, n as reportErrors, s as apiDownload } from "./ui-B1Px5y7q.mjs";
4
4
  import { defineCommand } from "citty";
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-CuyFMXam.mjs";
2
+ import { t as version } from "./package-BGDIsRxe.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";
@@ -74,15 +74,15 @@ const main = defineCommand({
74
74
  rollback: () => import("./deploy-CnxJ4TE6.mjs").then((m) => m.rollbackCommand),
75
75
  download: () => import("./deploy-CnxJ4TE6.mjs").then((m) => m.downloadCommand),
76
76
  token: () => import("./token-BUxgRJVA.mjs").then((m) => m.tokenCommand),
77
- egress: () => import("./egress-BFSwa8Uu.mjs").then((m) => m.egressCommand),
77
+ egress: () => import("./egress-PJp0SZy2.mjs").then((m) => m.egressCommand),
78
78
  schedule: () => import("./schedule-Cq9K0rnE.mjs").then((m) => m.scheduleCommand),
79
- feedback: () => import("./feedback-DReTDDzD.mjs").then((m) => m.feedbackCommand),
79
+ feedback: () => import("./feedback-B0iX3iZ1.mjs").then((m) => m.feedbackCommand),
80
80
  db: () => import("./db-DKnJQPm5.mjs").then((m) => m.dbCommand),
81
81
  usage: () => import("./usage-DjNngyvy.mjs").then((m) => m.usageCommand),
82
82
  open: () => import("./open-C5x3tX6I.mjs").then((m) => m.openCommand),
83
- docs: () => import("./docs-3pQHiuTn.mjs").then((m) => m.docsCommand),
84
- skill: () => import("./skill-M4RHvuUB.mjs").then((m) => m.skillCommand),
85
- upgrade: () => import("./upgrade-C8Xox-P-.mjs").then((m) => m.upgradeCommand),
83
+ docs: () => import("./docs-D7Ey1ZMQ.mjs").then((m) => m.docsCommand),
84
+ skill: () => import("./skill-Be_pqqdH.mjs").then((m) => m.skillCommand),
85
+ upgrade: () => import("./upgrade-ChwehW_q.mjs").then((m) => m.upgradeCommand),
86
86
  logs: () => import("./logs-C53y2eAS.mjs").then((m) => m.logsCommand)
87
87
  }
88
88
  });
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.7.14";
2
+ var version = "0.7.16";
3
3
  //#endregion
4
4
  export { version as t };
@@ -71,8 +71,9 @@ The CLI ships the full documentation and keeps it fresh from the platform. List
71
71
  the topics and read the relevant one _before_ running unfamiliar commands:
72
72
 
73
73
  ```bash
74
- canopy docs # list all topics
75
- canopy docs deploy # print one topic
74
+ canopy docs # list all topics
75
+ canopy docs deploy # print one topic
76
+ canopy docs search egress allowlist # find the topics that mention all terms
76
77
  ```
77
78
 
78
79
  The listing is grouped by section: guides (`getting-started`, `deploy`, `dev`,
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CuyFMXam.mjs";
1
+ import { t as version } from "./package-BGDIsRxe.mjs";
2
2
  import { n as reportErrors } from "./ui-B1Px5y7q.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -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-M4RHvuUB.mjs");
115
+ const { skillInstalled } = await import("./skill-Be_pqqdH.mjs");
116
116
  const refreshSkill = skillInstalled(process.cwd());
117
117
  console.log(`Upgrading via ${command}: ${command} ${cmd.join(" ")}`);
118
118
  await run(command, cmd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
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": "0.0.5",
24
- "@mediamonks/canopy-data-plane": "0.0.4"
23
+ "@mediamonks/canopy-data-plane": "0.0.4",
24
+ "@mediamonks/canopy": "0.0.5"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.1",
@@ -1,88 +0,0 @@
1
- import { a as projectDirArgs, c as resolveProject } from "./site-BUlQR117.mjs";
2
- import { c as apiRequest, n as reportErrors } from "./ui-B1Px5y7q.mjs";
3
- import { defineCommand } from "citty";
4
- //#region src/egress.ts
5
- /**
6
- * canopy egress — manage the project's outbound-host allowlist
7
- * (docs/16-egress-allowlist.md). Worker fetches to hosts outside the list are
8
- * denied (`egress_denied`) on targets that enforce egress control; the list
9
- * lives on the platform, not in canopy.json, so a repo file can never widen
10
- * egress silently on deploy.
11
- */
12
- function printList({ hosts, denied, enforced }, slug) {
13
- if (hosts.length === 0) console.log(`No extra egress hosts for ${slug} — the worker can reach platform hosts only.`);
14
- else for (const host of hosts) console.log(host);
15
- if (denied.length > 0) {
16
- console.log(`\nRecently blocked (allow with \`canopy egress allow <host>\`):`);
17
- for (const d of denied) console.log(` ${d.host} ×${d.count} last ${d.last_seen}`);
18
- }
19
- if (!enforced) console.error("(note: this site's target does not enforce egress yet — the list is recorded for when it does)");
20
- }
21
- const egressCommand = defineCommand({
22
- meta: {
23
- name: "egress",
24
- description: "Manage which outbound hosts the project's worker may fetch"
25
- },
26
- subCommands: {
27
- list: defineCommand({
28
- meta: {
29
- name: "list",
30
- description: "List the project's allowed outbound hosts"
31
- },
32
- args: { ...projectDirArgs },
33
- run: reportErrors("egress list", async ({ args }) => {
34
- const { slug } = resolveProject(args);
35
- printList(await apiRequest("GET", `/projects/${slug}/egress`), slug);
36
- })
37
- }),
38
- allow: defineCommand({
39
- meta: {
40
- name: "allow",
41
- description: "Allow the worker to fetch a host (e.g. api.example.com or *.googleapis.com)"
42
- },
43
- args: {
44
- host: {
45
- type: "positional",
46
- description: "Hostname to allow",
47
- required: true
48
- },
49
- ...projectDirArgs
50
- },
51
- run: reportErrors("egress allow", async ({ args }) => {
52
- const { slug } = resolveProject(args);
53
- const current = await apiRequest("GET", `/projects/${slug}/egress`);
54
- const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { hosts: [...current.hosts, args.host] });
55
- console.log(`Allowed ${args.host} for ${slug} — running workers pick it up now.\n`);
56
- printList(updated, slug);
57
- })
58
- }),
59
- remove: defineCommand({
60
- meta: {
61
- name: "remove",
62
- description: "Remove a host from the allowlist"
63
- },
64
- args: {
65
- host: {
66
- type: "positional",
67
- description: "Hostname to remove",
68
- required: true
69
- },
70
- ...projectDirArgs
71
- },
72
- run: reportErrors("egress remove", async ({ args }) => {
73
- const { slug } = resolveProject(args);
74
- const current = await apiRequest("GET", `/projects/${slug}/egress`);
75
- const wanted = args.host.trim().toLowerCase();
76
- if (!current.hosts.includes(wanted)) {
77
- console.error(`${wanted} is not on the allowlist for ${slug} (see \`canopy egress list\`).`);
78
- process.exit(1);
79
- }
80
- const updated = await apiRequest("PUT", `/projects/${slug}/egress`, { hosts: current.hosts.filter((h) => h !== wanted) });
81
- console.log(`Removed ${wanted} from ${slug} — running workers stop reaching it now.\n`);
82
- printList(updated, slug);
83
- })
84
- })
85
- }
86
- });
87
- //#endregion
88
- export { egressCommand };