@mediamonks/canopy-cli 0.7.23 → 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 (35) 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-XULukupE.mjs → dev-DVUEJ80c.mjs} +6 -6
  5. package/dist/{dev-worker-BDMjExps.mjs → dev-worker-CWU6XFe_.mjs} +1 -1
  6. package/dist/docs/ci.md +8 -1
  7. package/dist/docs/cli-egress.md +2 -1
  8. package/dist/docs/cli-token.md +4 -2
  9. package/dist/docs/platform-client-access.md +9 -0
  10. package/dist/docs/platform-egress.md +5 -3
  11. package/dist/docs/platform-project-data.md +1 -0
  12. package/dist/docs/sdk-database.md +30 -0
  13. package/dist/{docs-BDPL0TI3.mjs → docs-BLWio_65.mjs} +3 -3
  14. package/dist/{egress-BhbQfjjV.mjs → egress-BjN-7vil.mjs} +6 -3
  15. package/dist/{feedback-B3pnUPLf.mjs → feedback-Bnmi1Ukv.mjs} +3 -3
  16. package/dist/index.mjs +26 -26
  17. package/dist/{local-BjsuI1IB.mjs → local-CNPBZ89U.mjs} +3 -3
  18. package/dist/{login-By4y5O5-.mjs → login-COZ7Xcm0.mjs} +2 -2
  19. package/dist/{logs-Beqz--Df.mjs → logs-B_YoflCq.mjs} +2 -2
  20. package/dist/{migrations-Bg9FEGFh.mjs → migrations-Btzr9_q7.mjs} +2 -2
  21. package/dist/{open-D7f1wNn3.mjs → open-D2hH933K.mjs} +2 -2
  22. package/dist/{package-C5rZwlw8.mjs → package-DTm0EzxR.mjs} +1 -1
  23. package/dist/{projects-1r5387ui.mjs → projects-CLs6MEF7.mjs} +2 -2
  24. package/dist/{schedule-DK_Cv6Xf.mjs → schedule-B8zA8WlG.mjs} +2 -2
  25. package/dist/{site-BFaWfSVu.mjs → site-DM2J10CB.mjs} +1 -1
  26. package/dist/{skill-CgToW_Ac.mjs → skill-Dt49jdUT.mjs} +2 -2
  27. package/dist/{src-BPKfY9IN.mjs → src-BK0Hiwsn.mjs} +42 -2
  28. package/dist/{staging-CdpV2YHB.mjs → staging-pZpdZuj_.mjs} +2 -2
  29. package/dist/{token-C8Bmzwfl.mjs → token-BKwGa10J.mjs} +8 -4
  30. package/dist/{ui--atbZ-TM.mjs → ui-CUNkis89.mjs} +1 -1
  31. package/dist/{upgrade-Bl_NJ_m5.mjs → upgrade-Cf81feok.mjs} +2 -2
  32. package/dist/{usage-C1-cvS2-.mjs → usage-UmzU6sGp.mjs} +2 -2
  33. package/package.json +1 -1
  34. package/dist/db-TaSLpQZ5.mjs +0 -204
  35. package/dist/dev-BFPlhkzp.mjs +0 -2
@@ -0,0 +1,251 @@
1
+ import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
2
+ import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, s as apiDataRequest } from "./ui-CUNkis89.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Btzr9_q7.mjs";
4
+ import { defineCommand } from "citty";
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import * as p from "@clack/prompts";
7
+ //#region src/db.ts
8
+ /**
9
+ * canopy db pull | snapshot | snapshots | restore — data safety from the
10
+ * terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
11
+ *
12
+ * pull download a fresh SQL dump of the project database
13
+ * snapshot store a dump server-side (restorable from CLI or dashboard)
14
+ * snapshots list stored snapshots
15
+ * restore replace ALL project data from a snapshot or a local .sql file
16
+ * migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
17
+ * access show or set what the app's pages may do with the database (docs/35)
18
+ *
19
+ * The project comes from canopy.json in the current directory, or
20
+ * --project <slug>.
21
+ */
22
+ /** Shared flag: operate on the staging channel's database (docs/14). */
23
+ const stagingFlagArgs = { staging: {
24
+ type: "boolean",
25
+ default: false,
26
+ description: "Target the staging channel's database instead of live"
27
+ } };
28
+ const channelOf = (args) => args.staging ? "staging" : "live";
29
+ const channelNote = (args) => args.staging ? " [staging]" : "";
30
+ const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
31
+ const pullCommand = defineCommand({
32
+ meta: {
33
+ name: "pull",
34
+ description: "Download a fresh SQL dump of the project database"
35
+ },
36
+ args: {
37
+ ...projectFlagArgs,
38
+ ...stagingFlagArgs,
39
+ out: {
40
+ type: "string",
41
+ description: "Output file (defaults to <slug>-<timestamp>.sql)"
42
+ }
43
+ },
44
+ run: reportErrors("db pull", async ({ args }) => {
45
+ const { slug } = resolveProject(args);
46
+ const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
47
+ const spin = p.spinner();
48
+ spin.start(`Exporting ${slug}${channelNote(args)}…`);
49
+ try {
50
+ const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
51
+ const download = await fetch(url);
52
+ if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
53
+ const sql = Buffer.from(await download.arrayBuffer());
54
+ writeFileSync(out, sql);
55
+ spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
56
+ } catch (err) {
57
+ spin.error("Export failed.");
58
+ throw err;
59
+ }
60
+ })
61
+ });
62
+ const snapshotCommand = defineCommand({
63
+ meta: {
64
+ name: "snapshot",
65
+ description: "Store a dump server-side (restorable anytime)"
66
+ },
67
+ args: {
68
+ ...projectFlagArgs,
69
+ ...stagingFlagArgs
70
+ },
71
+ run: reportErrors("db snapshot", async ({ args }) => {
72
+ const { slug } = resolveProject(args);
73
+ const spin = p.spinner();
74
+ spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
75
+ let created;
76
+ try {
77
+ ({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
78
+ } catch (err) {
79
+ spin.error("Snapshot failed.");
80
+ throw err;
81
+ }
82
+ spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
83
+ console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
84
+ })
85
+ });
86
+ const snapshotsCommand = defineCommand({
87
+ meta: {
88
+ name: "snapshots",
89
+ description: "List stored snapshots"
90
+ },
91
+ args: {
92
+ ...projectFlagArgs,
93
+ ...stagingFlagArgs
94
+ },
95
+ run: reportErrors("db snapshots", async ({ args }) => {
96
+ const { slug } = resolveProject(args);
97
+ const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
98
+ if (snapshots.length === 0) {
99
+ console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
100
+ return;
101
+ }
102
+ for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
103
+ })
104
+ });
105
+ const restoreCommand = defineCommand({
106
+ meta: {
107
+ name: "restore",
108
+ description: "Replace ALL project data from a snapshot or a local .sql file"
109
+ },
110
+ args: {
111
+ ...projectFlagArgs,
112
+ ...stagingFlagArgs,
113
+ file: {
114
+ type: "positional",
115
+ description: "Local .sql dump to restore from",
116
+ required: false
117
+ },
118
+ snapshot: {
119
+ type: "string",
120
+ description: "Server-side snapshot key to restore from"
121
+ },
122
+ yes: {
123
+ type: "boolean",
124
+ default: false,
125
+ description: "Skip the type-the-slug confirmation"
126
+ }
127
+ },
128
+ run: reportErrors("db restore", async ({ args }) => {
129
+ const { slug } = resolveProject(args);
130
+ const { file } = args;
131
+ let snapshotKey = args.snapshot;
132
+ if (!snapshotKey && !file) {
133
+ requireTty("pass a local .sql dump or --snapshot <key>.");
134
+ const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
135
+ if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
136
+ snapshotKey = unwrap(await p.select({
137
+ message: `Snapshot to restore into ${slug}`,
138
+ options: snapshots.map((snapshot) => ({
139
+ value: snapshot.key,
140
+ label: snapshot.key,
141
+ hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
142
+ }))
143
+ }));
144
+ }
145
+ if (!args.yes) {
146
+ requireTty("pass --yes to restore without confirmation.");
147
+ if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}"${channelNote(args)} with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
148
+ p.cancel("Aborted.");
149
+ return;
150
+ }
151
+ }
152
+ const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
153
+ if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }, void 0, channelOf(args)), restoreContext);
154
+ else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql", channelOf(args)), restoreContext);
155
+ p.log.success(`Restored ${slug}${channelNote(args)}.`);
156
+ })
157
+ });
158
+ const migrateCommand = defineCommand({
159
+ meta: {
160
+ name: "migrate",
161
+ description: "Apply pending schema migrations to the project database"
162
+ },
163
+ args: {
164
+ ...projectFlagArgs,
165
+ ...stagingFlagArgs,
166
+ plan: {
167
+ type: "boolean",
168
+ default: false,
169
+ description: "Dry run: list pending migrations without applying anything"
170
+ }
171
+ },
172
+ run: reportErrors("db migrate", async ({ args }) => {
173
+ const { dir, slug } = resolveProject(args);
174
+ const files = readMigrationFiles(dir);
175
+ if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
176
+ if (args.plan) {
177
+ const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
178
+ if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
179
+ else for (const name of result.pending) console.log(`pending ${name}`);
180
+ for (const line of describeMigrateResult(result)) console.log(line);
181
+ return;
182
+ }
183
+ const spin = p.spinner();
184
+ spin.start(`Migrating ${slug}${channelNote(args)}…`);
185
+ let result;
186
+ try {
187
+ result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
188
+ } catch (err) {
189
+ spin.error("Migrate failed.");
190
+ throw err;
191
+ }
192
+ spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
193
+ for (const line of describeMigrateResult(result)) console.log(` ${line}`);
194
+ })
195
+ });
196
+ const LEVEL_MEANING = {
197
+ full: "pages can read and write the database (collections and raw SQL)",
198
+ readonly: "pages can read; SQL that writes and collection writes are refused",
199
+ off: "pages cannot touch the database — only the worker and the CLI can"
200
+ };
201
+ function printAccess(slug, access) {
202
+ console.log(`${slug}: browser access is ${access.level} — ${LEVEL_MEANING[access.level]}.`);
203
+ console.log("Tables and collections named private_… are worker-only at every level.");
204
+ if (access.clientAccess && access.level !== "off") {
205
+ console.log("");
206
+ console.log(` ! Client access is on: visitors who know the password can ${access.level === "full" ? "read and change" : "read"} this data from their browser.`);
207
+ console.log(" Name tables private_… to keep them worker-only, or `canopy db access off`.");
208
+ }
209
+ }
210
+ const dbCommand = defineCommand({
211
+ meta: {
212
+ name: "db",
213
+ description: "Data tools: pull, snapshot, snapshots, restore, migrate, access"
214
+ },
215
+ subCommands: {
216
+ pull: pullCommand,
217
+ snapshot: snapshotCommand,
218
+ snapshots: snapshotsCommand,
219
+ restore: restoreCommand,
220
+ migrate: migrateCommand,
221
+ access: defineCommand({
222
+ meta: {
223
+ name: "access",
224
+ description: "Show or set what the app's own pages may do with the database (full | readonly | off)"
225
+ },
226
+ args: {
227
+ ...projectFlagArgs,
228
+ level: {
229
+ type: "positional",
230
+ description: "full | readonly | off (omit to show)",
231
+ required: false
232
+ }
233
+ },
234
+ run: reportErrors("db access", async ({ args }) => {
235
+ const { slug } = resolveProject(args);
236
+ const wanted = args.level?.trim().toLowerCase();
237
+ if (!wanted) {
238
+ printAccess(slug, await apiRequest("GET", `/projects/${slug}/db-access`));
239
+ return;
240
+ }
241
+ if (wanted !== "full" && wanted !== "readonly" && wanted !== "off") {
242
+ console.error(`unknown level "${wanted}" — expected full, readonly or off.`);
243
+ process.exit(1);
244
+ }
245
+ printAccess(slug, await apiRequest("PUT", `/projects/${slug}/db-access`, { level: wanted }));
246
+ })
247
+ })
248
+ }
249
+ });
250
+ //#endregion
251
+ export { dbCommand };
@@ -1,6 +1,6 @@
1
- import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
2
- import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui--atbZ-TM.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Bg9FEGFh.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
+ import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui-CUNkis89.mjs";
3
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Btzr9_q7.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-DVUEJ80c.mjs";
2
+ export { devCommand };
@@ -1,6 +1,6 @@
1
- import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
2
- import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui--atbZ-TM.mjs";
3
- import { r as readMigrationFiles } from "./migrations-Bg9FEGFh.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
+ import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
3
+ import { r as readMigrationFiles } from "./migrations-Btzr9_q7.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { defineCommand } from "citty";
6
6
  import { existsSync } from "node:fs";
@@ -37,7 +37,7 @@ async function workerPlugin(flags, slug) {
37
37
  console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
38
38
  return;
39
39
  }
40
- const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-BDMjExps.mjs");
40
+ const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CWU6XFe_.mjs");
41
41
  const secrets = readDevVars(flags.dir);
42
42
  const email = siteConfig().email;
43
43
  const plugin = workerDevPlugin({
@@ -165,7 +165,7 @@ async function devRemote(flags, slug) {
165
165
  async function devLocal(flags, slug) {
166
166
  let dump;
167
167
  if (flags.pull) {
168
- const { fetchProjectDump } = await import("./local-BjsuI1IB.mjs");
168
+ const { fetchProjectDump } = await import("./local-CNPBZ89U.mjs");
169
169
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
170
170
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
171
171
  }
@@ -180,7 +180,7 @@ async function devLocal(flags, slug) {
180
180
  const reason = err instanceof ApiError ? err.explain().why : String(err);
181
181
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
182
182
  }
183
- const { createSandbox } = await import("./local-BjsuI1IB.mjs");
183
+ const { createSandbox } = await import("./local-CNPBZ89U.mjs");
184
184
  const sandbox = await createSandbox({
185
185
  slug,
186
186
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -1,4 +1,4 @@
1
- import "./src-BPKfY9IN.mjs";
1
+ import "./src-BK0Hiwsn.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve, sep } from "node:path";
4
4
  //#region ../data-plane/src/worker-request.ts
package/dist/docs/ci.md CHANGED
@@ -9,7 +9,8 @@ can deploy that one project and nothing else.
9
9
 
10
10
  ## Create a token
11
11
 
12
- From inside the project directory:
12
+ Only the project owner can create a deploy token (any collaborator can list
13
+ and revoke them). From inside the project directory:
13
14
 
14
15
  ```bash
15
16
  canopy token create --name "GitHub Actions"
@@ -47,4 +48,10 @@ No `canopy login` step is needed — the token is the credential.
47
48
  - A deploy token can only deploy its own project — it reaches that project's
48
49
  deploy endpoints and upload grants and nothing else. If it strays to another
49
50
  project's resources, the platform rejects it.
51
+ - The token belongs to the project, not to the person who created it. It keeps
52
+ working when they leave the project or the company; deploys it makes show up
53
+ in the history under the token's name.
54
+ - A token that goes 90 days without a deploy stops working. Active pipelines
55
+ never notice; a forgotten one dies on its own. The token list shows when each
56
+ was last used.
50
57
  - Revoke a token the moment it might be exposed; create a fresh one to replace it.
@@ -32,7 +32,8 @@ target directory, or `--project <slug>` to override — and honor the global
32
32
  - `mode [allowlist|open]` — show or switch the
33
33
  [egress mode](https://docs.canopy.website/platform/egress#open-mode). Switching to `open` prints a risk
34
34
  summary and asks you to type the project slug; pass `--accept-risk` to
35
- acknowledge it non-interactively. Switching back needs no ceremony.
35
+ acknowledge it non-interactively. Only the project owner can switch to
36
+ `open`; switching back needs no ceremony and any member can do it.
36
37
  - `log` — print the [outbound traffic log](https://docs.canopy.website/platform/egress#the-outbound-traffic-log):
37
38
  hosts the worker actually fetched, with counts and last-seen times.
38
39
 
@@ -17,15 +17,17 @@ canopy token revoke <id>
17
17
 
18
18
  All subcommands resolve the project the usual way — `canopy.json` in the target directory, or `--project <slug>` to override — and honor the global `--profile <site>` / `CANOPY_PROFILE` for which site the token belongs to.
19
19
 
20
- - `create` — mint a new deploy token for the project.
20
+ - `create` — mint a new deploy token for the project. Owner only: collaborators get a clear error and should ask the owner.
21
21
  - `--name <label>` — a label shown in the dashboard, e.g. `"GitHub Actions"`. Optional but recommended so you can tell tokens apart later.
22
- - `list` — show the project's deploy tokens: id, name, creation time, and who created them. Token values are never shown here.
22
+ - `list` — show the project's deploy tokens: id, name, creation time, who created them, and when each was last used. Token values are never shown here.
23
23
  - `revoke <id>` — delete a token by its id (from `canopy token list`). Deploys using it stop working immediately.
24
24
 
25
25
  ## How it works
26
26
 
27
27
  `create` prints the token plaintext **exactly once** — the platform stores only a hash, so copy it right away. The token is scoped to a single project on a single site: it can deploy that project and nothing else, which makes it safe to hand to a CI system.
28
28
 
29
+ The token belongs to the project rather than to whoever created it, so CI keeps working when that person leaves. It has no fixed expiry but stops working after 90 days without a deploy; `list` shows when each token was last used.
30
+
29
31
  To use it in CI, put the value in the `CANOPY_DEPLOY_TOKEN` environment variable — the CLI checks it before the login token, so `canopy deploy` works on a runner with no `canopy login`. For GitHub Actions, add it as the repo secret `CANOPY_DEPLOY_TOKEN` (`gh secret set CANOPY_DEPLOY_TOKEN`, or Settings → Secrets); pushes to main then deploy via the project's `canopy-deploy.yml` workflow.
30
32
 
31
33
  If a deploy fails with a rejected token, it was likely revoked — create a fresh one here or in the dashboard (Settings → Deploy tokens) and update the secret. Losing a token is never a problem: revoke the old id and mint a new one.
@@ -54,4 +54,13 @@ the app down for Monks outside the team while a client with the password
54
54
  still gets in. That combination — "the client and the project team, nobody
55
55
  else" — is a common setup during a review phase.
56
56
 
57
+ The password opens the app's data the same way it opens the app. Whatever a
58
+ page may do with the project database under **Browser access** on the Data
59
+ tab — read and write by default — a visitor who knows the password may do
60
+ too, from their own browser. The dashboard points this out wherever the two
61
+ settings meet. If the project has tables the client should not see or change,
62
+ name them `private_…` so only the worker can reach them, or set browser
63
+ access to read only or off. See
64
+ [Who can call this from the browser](https://docs.canopy.website/sdk/database#who-can-call-this-from-the-browser).
65
+
57
66
  - [Members & access](https://docs.canopy.website/platform/members-and-access) — Who can view and who can manage a project, and how to tighten it.
@@ -102,9 +102,11 @@ allowlist:
102
102
  Because open mode means _users of your app can decide where it connects_,
103
103
  enabling it is a deliberate act: the dashboard asks you to type the project
104
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.
105
+ confirmation or the explicit `--accept-risk` flag. Only the **project owner**
106
+ can switch open mode on; other members can still manage the allowlist and
107
+ switch back. Extra care applies if the project has
108
+ [client access](https://docs.canopy.website/platform/client-access) enabled — then untrusted visitors
109
+ drive the fetches.
108
110
 
109
111
  Switching back is one command (`canopy egress mode allowlist`), and the
110
112
  dashboard offers to **seed the allowlist from the observed hosts** so
@@ -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,
@@ -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).
@@ -1,7 +1,7 @@
1
- import { t as version } from "./package-C5rZwlw8.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-C5rZwlw8.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-C5rZwlw8.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-By4y5O5-.mjs").then((m) => m.loginCommand),
64
- whoami: () => import("./login-By4y5O5-.mjs").then((m) => m.whoamiCommand),
65
- logout: () => import("./login-By4y5O5-.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-BFPlhkzp.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-B3pnUPLf.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-BDPL0TI3.mjs").then((m) => m.docsCommand),
84
- skill: () => import("./skill-CgToW_Ac.mjs").then((m) => m.skillCommand),
85
- upgrade: () => import("./upgrade-Bl_NJ_m5.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-BPKfY9IN.mjs";
3
- import { n as rewriteApiPath } from "./dev-XULukupE.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";
@@ -1,5 +1,5 @@
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
4
  import { hostname } from "node:os";
5
5
  import * as p from "@clack/prompts";
@@ -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.23";
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";
@@ -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, t as formatDateTime } 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, t as formatDateTime } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/schedule.ts
5
5
  /**
@@ -170,7 +170,7 @@ function clearAllProfileTokens() {
170
170
  function projectUrl(slug) {
171
171
  const config = siteConfig();
172
172
  const apex = config.projectDomain?.trim();
173
- if (apex) return `${config.projectProtocol ?? (apex.includes("sslip.io") ? "http" : "https")}://${slug}.${apex.replace(/^\./, "")}`;
173
+ if (apex) return `${config.projectProtocol ?? "https"}://${slug}.${apex.replace(/^\./, "")}`;
174
174
  const apiHost = new URL(config.apiUrl).hostname;
175
175
  if (apiHost === "run.app" || apiHost.endsWith(".run.app") || apiHost.startsWith("api.")) throw new Error("Cannot derive the project URL. Set projectDomain (baked profile or CANOPY_PROJECT_DOMAIN).");
176
176
  return `https://${slug}.${apiHost.split(".").slice(-2).join(".")}`;
@@ -1,5 +1,5 @@
1
- import { t as version } from "./package-C5rZwlw8.mjs";
2
- import { r as reportErrors } from "./ui--atbZ-TM.mjs";
1
+ import { t as version } from "./package-DTm0EzxR.mjs";
2
+ import { r as reportErrors } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
@@ -384,8 +384,21 @@ function dbRouter(config) {
384
384
  error: "invalid_query",
385
385
  detail: "expected { query: string, params?: (string|number|boolean|null)[], readonly?: boolean }"
386
386
  }, 400);
387
+ const verdict = await config.guard?.(c, {
388
+ kind: "sql",
389
+ readonly: body.readonly
390
+ }, resolved);
391
+ if (verdict instanceof Response) return verdict;
392
+ const forced = verdict?.readonly === true;
393
+ if (forced) {
394
+ const violation = readOnlySqlViolation(body.query);
395
+ if (violation) return c.json({
396
+ error: "browser_db_access",
397
+ detail: `${violation}. ${verdict.refusal}`
398
+ }, 403);
399
+ }
387
400
  if (body.readonly === void 0) {
388
- const result = await store.query(body.query, params).catch((err) => err);
401
+ const result = await store.query(body.query, params, forced ? { readonly: true } : void 0).catch((err) => err);
389
402
  if (result instanceof Error) return c.json({
390
403
  error: "sql_error",
391
404
  detail: result.message
@@ -399,7 +412,7 @@ function dbRouter(config) {
399
412
  detail: violation
400
413
  }, 400);
401
414
  }
402
- const result = await store.query(body.query, params, { readonly: body.readonly }).catch((err) => err);
415
+ const result = await store.query(body.query, params, { readonly: body.readonly || forced }).catch((err) => err);
403
416
  if (result instanceof Error) return c.json({
404
417
  error: "sql_error",
405
418
  detail: result.message
@@ -416,6 +429,8 @@ function dbRouter(config) {
416
429
  db.get("/db/schema", async (c) => {
417
430
  const resolved = await resolve(c);
418
431
  if (resolved instanceof Response) return resolved;
432
+ const verdict = await config.guard?.(c, { kind: "schema" }, resolved);
433
+ if (verdict instanceof Response) return verdict;
419
434
  const schema = await introspectSchema(resolved.store, resolved.dialect.name).catch((err) => err);
420
435
  if (schema instanceof Error) return c.json({
421
436
  error: "sql_error",
@@ -429,6 +444,11 @@ function dbRouter(config) {
429
444
  const { store, dialect } = resolved;
430
445
  const collection = c.req.param("collection");
431
446
  if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
447
+ const verdict = await config.guard?.(c, {
448
+ kind: "write",
449
+ collection
450
+ }, resolved);
451
+ if (verdict instanceof Response) return verdict;
432
452
  const body = await c.req.json().catch(() => void 0);
433
453
  if (!isPlainObject(body)) return c.json({
434
454
  error: "invalid_document",
@@ -457,6 +477,11 @@ function dbRouter(config) {
457
477
  const { store, dialect } = resolved;
458
478
  const collection = c.req.param("collection");
459
479
  if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
480
+ const verdict = await config.guard?.(c, {
481
+ kind: "read",
482
+ collection
483
+ }, resolved);
484
+ if (verdict instanceof Response) return verdict;
460
485
  const q = c.req.query();
461
486
  try {
462
487
  const limit = intParam(q.limit, "limit", 100, MAX_LIMIT);
@@ -509,6 +534,11 @@ function dbRouter(config) {
509
534
  const { store, dialect } = resolved;
510
535
  const collection = c.req.param("collection");
511
536
  if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
537
+ const verdict = await config.guard?.(c, {
538
+ kind: "read",
539
+ collection
540
+ }, resolved);
541
+ if (verdict instanceof Response) return verdict;
512
542
  const get = dialect.get(collection, c.req.param("id"));
513
543
  const row = (await store.query(get.sql, get.params)).results[0];
514
544
  return c.json(row ? toDoc(row) : null);
@@ -519,6 +549,11 @@ function dbRouter(config) {
519
549
  const { store, dialect } = resolved;
520
550
  const collection = c.req.param("collection");
521
551
  if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
552
+ const verdict = await config.guard?.(c, {
553
+ kind: "write",
554
+ collection
555
+ }, resolved);
556
+ if (verdict instanceof Response) return verdict;
522
557
  const body = await c.req.json().catch(() => void 0);
523
558
  if (!isPlainObject(body)) return c.json({
524
559
  error: "invalid_patch",
@@ -535,6 +570,11 @@ function dbRouter(config) {
535
570
  const { store, dialect } = resolved;
536
571
  const collection = c.req.param("collection");
537
572
  if (!COLLECTION_RE.test(collection)) return c.json({ error: "invalid_collection" }, 400);
573
+ const verdict = await config.guard?.(c, {
574
+ kind: "write",
575
+ collection
576
+ }, resolved);
577
+ if (verdict instanceof Response) return verdict;
538
578
  const del = dialect.del(collection, c.req.param("id"));
539
579
  const result = await store.query(del.sql, del.params);
540
580
  return c.json({ deleted: (result.meta.changes ?? 0) > 0 });
@@ -1,5 +1,5 @@
1
- import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } from "./site-BFaWfSVu.mjs";
2
- import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui--atbZ-TM.mjs";
1
+ import { c as resolveProject, l as stagingUrl, o as projectFlagArgs, s as projectUrl } 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/staging.ts
@@ -1,5 +1,5 @@
1
- import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-BFaWfSVu.mjs";
2
- import { l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui--atbZ-TM.mjs";
1
+ import { a as projectDirArgs, c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
2
+ import { l as apiRequest, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  const tokenCommand = defineCommand({
5
5
  meta: {
@@ -21,9 +21,10 @@ const tokenCommand = defineCommand({
21
21
  },
22
22
  run: reportErrors("token create", async ({ args }) => {
23
23
  const { slug } = resolveProject(args);
24
- const { token } = await apiRequest("POST", `/projects/${slug}/deploy-tokens`, { name: args.name });
24
+ const { token, idle_days } = await apiRequest("POST", `/projects/${slug}/deploy-tokens`, { name: args.name });
25
25
  console.log(`Deploy token for ${slug} (shown once — store it now):\n`);
26
26
  console.log(` ${token}\n`);
27
+ console.log(`It belongs to the project and stops working after ${idle_days} days without a deploy.`);
27
28
  console.log("Add it to the GitHub repo as secret CANOPY_DEPLOY_TOKEN");
28
29
  console.log("(gh secret set CANOPY_DEPLOY_TOKEN, or repo Settings → Secrets).");
29
30
  console.log("Pushes to main then deploy via .github/workflows/canopy-deploy.yml.");
@@ -42,7 +43,10 @@ const tokenCommand = defineCommand({
42
43
  console.log(`No deploy tokens for ${slug} — create one with \`canopy token create\`.`);
43
44
  return;
44
45
  }
45
- for (const t of tokens) console.log(`${t.id} ${t.name ?? "(unnamed)"} created ${formatDateTime(t.created_at)} by ${t.created_by ?? "?"}`);
46
+ for (const t of tokens) {
47
+ const used = t.last_used_at ? `last used ${formatDateTime(t.last_used_at)}` : "never used";
48
+ console.log(`${t.id} ${t.name ?? "(unnamed)"} created ${formatDateTime(t.created_at)} by ${t.created_by ?? "?"} ${used}`);
49
+ }
46
50
  })
47
51
  }),
48
52
  revoke: defineCommand({
@@ -1,4 +1,4 @@
1
- import { n as siteConfig } from "./site-BFaWfSVu.mjs";
1
+ import { n as siteConfig } from "./site-DM2J10CB.mjs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { cancel, isCancel } from "@clack/prompts";
4
4
  //#region src/api.ts
@@ -1,5 +1,5 @@
1
1
  import { t as PACKAGE_NAME } from "./version-check-CcsA1QjI.mjs";
2
- import { r as reportErrors } from "./ui--atbZ-TM.mjs";
2
+ import { r as reportErrors } from "./ui-CUNkis89.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { spawn } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
@@ -112,7 +112,7 @@ const upgradeCommand = defineCommand({
112
112
  const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
113
113
  const command = pmCommand(pm);
114
114
  const cmd = installArgs(pm, spec);
115
- const { skillInstalled } = await import("./skill-CgToW_Ac.mjs");
115
+ const { skillInstalled } = await import("./skill-Dt49jdUT.mjs");
116
116
  const { homedir } = await import("node:os");
117
117
  const refreshGlobal = skillInstalled(homedir());
118
118
  const refreshRepo = process.cwd() !== homedir() && skillInstalled(process.cwd());
@@ -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/usage.ts
5
5
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.7.23",
3
+ "version": "0.7.24",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -1,204 +0,0 @@
1
- import { c as resolveProject, o as projectFlagArgs } from "./site-BFaWfSVu.mjs";
2
- import { a as unwrap, d as withContext, i as requireTty, r as reportErrors, s as apiDataRequest } from "./ui--atbZ-TM.mjs";
3
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Bg9FEGFh.mjs";
4
- import { defineCommand } from "citty";
5
- import { readFileSync, writeFileSync } from "node:fs";
6
- import * as p from "@clack/prompts";
7
- //#region src/db.ts
8
- /**
9
- * canopy db pull | snapshot | snapshots | restore — data safety from the
10
- * terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
11
- *
12
- * pull download a fresh SQL dump of the project database
13
- * snapshot store a dump server-side (restorable from CLI or dashboard)
14
- * snapshots list stored snapshots
15
- * restore replace ALL project data from a snapshot or a local .sql file
16
- * migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
17
- *
18
- * The project comes from canopy.json in the current directory, or
19
- * --project <slug>.
20
- */
21
- /** Shared flag: operate on the staging channel's database (docs/14). */
22
- const stagingFlagArgs = { staging: {
23
- type: "boolean",
24
- default: false,
25
- description: "Target the staging channel's database instead of live"
26
- } };
27
- const channelOf = (args) => args.staging ? "staging" : "live";
28
- const channelNote = (args) => args.staging ? " [staging]" : "";
29
- const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
30
- const dbCommand = defineCommand({
31
- meta: {
32
- name: "db",
33
- description: "Data tools: pull, snapshot, snapshots, restore, migrate"
34
- },
35
- subCommands: {
36
- pull: defineCommand({
37
- meta: {
38
- name: "pull",
39
- description: "Download a fresh SQL dump of the project database"
40
- },
41
- args: {
42
- ...projectFlagArgs,
43
- ...stagingFlagArgs,
44
- out: {
45
- type: "string",
46
- description: "Output file (defaults to <slug>-<timestamp>.sql)"
47
- }
48
- },
49
- run: reportErrors("db pull", async ({ args }) => {
50
- const { slug } = resolveProject(args);
51
- const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
52
- const spin = p.spinner();
53
- spin.start(`Exporting ${slug}${channelNote(args)}…`);
54
- try {
55
- const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
56
- const download = await fetch(url);
57
- if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
58
- const sql = Buffer.from(await download.arrayBuffer());
59
- writeFileSync(out, sql);
60
- spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
61
- } catch (err) {
62
- spin.error("Export failed.");
63
- throw err;
64
- }
65
- })
66
- }),
67
- snapshot: defineCommand({
68
- meta: {
69
- name: "snapshot",
70
- description: "Store a dump server-side (restorable anytime)"
71
- },
72
- args: {
73
- ...projectFlagArgs,
74
- ...stagingFlagArgs
75
- },
76
- run: reportErrors("db snapshot", async ({ args }) => {
77
- const { slug } = resolveProject(args);
78
- const spin = p.spinner();
79
- spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
80
- let created;
81
- try {
82
- ({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
83
- } catch (err) {
84
- spin.error("Snapshot failed.");
85
- throw err;
86
- }
87
- spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
88
- console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
89
- })
90
- }),
91
- snapshots: defineCommand({
92
- meta: {
93
- name: "snapshots",
94
- description: "List stored snapshots"
95
- },
96
- args: {
97
- ...projectFlagArgs,
98
- ...stagingFlagArgs
99
- },
100
- run: reportErrors("db snapshots", async ({ args }) => {
101
- const { slug } = resolveProject(args);
102
- const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
103
- if (snapshots.length === 0) {
104
- console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
105
- return;
106
- }
107
- for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
108
- })
109
- }),
110
- restore: defineCommand({
111
- meta: {
112
- name: "restore",
113
- description: "Replace ALL project data from a snapshot or a local .sql file"
114
- },
115
- args: {
116
- ...projectFlagArgs,
117
- ...stagingFlagArgs,
118
- file: {
119
- type: "positional",
120
- description: "Local .sql dump to restore from",
121
- required: false
122
- },
123
- snapshot: {
124
- type: "string",
125
- description: "Server-side snapshot key to restore from"
126
- },
127
- yes: {
128
- type: "boolean",
129
- default: false,
130
- description: "Skip the type-the-slug confirmation"
131
- }
132
- },
133
- run: reportErrors("db restore", async ({ args }) => {
134
- const { slug } = resolveProject(args);
135
- const { file } = args;
136
- let snapshotKey = args.snapshot;
137
- if (!snapshotKey && !file) {
138
- requireTty("pass a local .sql dump or --snapshot <key>.");
139
- const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
140
- if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
141
- snapshotKey = unwrap(await p.select({
142
- message: `Snapshot to restore into ${slug}`,
143
- options: snapshots.map((snapshot) => ({
144
- value: snapshot.key,
145
- label: snapshot.key,
146
- hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
147
- }))
148
- }));
149
- }
150
- if (!args.yes) {
151
- requireTty("pass --yes to restore without confirmation.");
152
- if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}"${channelNote(args)} with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
153
- p.cancel("Aborted.");
154
- return;
155
- }
156
- }
157
- const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
158
- if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }, void 0, channelOf(args)), restoreContext);
159
- else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql", channelOf(args)), restoreContext);
160
- p.log.success(`Restored ${slug}${channelNote(args)}.`);
161
- })
162
- }),
163
- migrate: defineCommand({
164
- meta: {
165
- name: "migrate",
166
- description: "Apply pending schema migrations to the project database"
167
- },
168
- args: {
169
- ...projectFlagArgs,
170
- ...stagingFlagArgs,
171
- plan: {
172
- type: "boolean",
173
- default: false,
174
- description: "Dry run: list pending migrations without applying anything"
175
- }
176
- },
177
- run: reportErrors("db migrate", async ({ args }) => {
178
- const { dir, slug } = resolveProject(args);
179
- const files = readMigrationFiles(dir);
180
- if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
181
- if (args.plan) {
182
- const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
183
- if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
184
- else for (const name of result.pending) console.log(`pending ${name}`);
185
- for (const line of describeMigrateResult(result)) console.log(line);
186
- return;
187
- }
188
- const spin = p.spinner();
189
- spin.start(`Migrating ${slug}${channelNote(args)}…`);
190
- let result;
191
- try {
192
- result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
193
- } catch (err) {
194
- spin.error("Migrate failed.");
195
- throw err;
196
- }
197
- spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
198
- for (const line of describeMigrateResult(result)) console.log(` ${line}`);
199
- })
200
- })
201
- }
202
- });
203
- //#endregion
204
- export { dbCommand };
@@ -1,2 +0,0 @@
1
- import { t as devCommand } from "./dev-XULukupE.mjs";
2
- export { devCommand };