@mediamonks/canopy-cli 0.7.14 → 0.7.15

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.
@@ -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-CF0dX7UA.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";
@@ -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-CF0dX7UA.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-CF0dX7UA.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-h_j6DBBZ.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-BlzdYmou.mjs").then((m) => m.docsCommand),
84
+ skill: () => import("./skill-DaaxgqEk.mjs").then((m) => m.skillCommand),
85
+ upgrade: () => import("./upgrade-DG-7xwOs.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.15";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CuyFMXam.mjs";
1
+ import { t as version } from "./package-CF0dX7UA.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-DaaxgqEk.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.15",
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 };