@mediamonks/canopy-cli 0.4.5 → 0.5.1

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 (37) hide show
  1. package/README.md +43 -0
  2. package/dist/{db-PpaU3V-B.mjs → db-BW1ZolWn.mjs} +8 -11
  3. package/dist/{deploy-B064KXtr.mjs → deploy-alC2--pZ.mjs} +92 -12
  4. package/dist/dev-BJ5xEwqW.mjs +2 -0
  5. package/dist/{dev-CuvlJpcO.mjs → dev-CQQhMOoD.mjs} +22 -24
  6. package/dist/docs/ci.md +6 -2
  7. package/dist/docs/cli.md +8 -3
  8. package/dist/docs/data.md +22 -4
  9. package/dist/docs/deploy.md +10 -6
  10. package/dist/docs/dev.md +10 -3
  11. package/dist/docs/infrastructure.md +397 -0
  12. package/dist/docs/static-serving.md +318 -0
  13. package/dist/docs/troubleshooting.md +14 -0
  14. package/dist/{docs-UYzmzpz-.mjs → docs-DvSPpSIR.mjs} +8 -7
  15. package/dist/index.mjs +53 -19
  16. package/dist/local-C5-GWJcx.mjs +826 -0
  17. package/dist/login-BO6abf40.mjs +383 -0
  18. package/dist/{open-CBuZBJlv.mjs → open-DnlYDqrU.mjs} +2 -2
  19. package/dist/{package-Bnu23N0A.mjs → package-CHoZKzaw.mjs} +1 -1
  20. package/dist/{projects-CK4DNxgZ.mjs → projects-ijfe7Ffx.mjs} +21 -3
  21. package/dist/site-BJjoePQS.mjs +254 -0
  22. package/dist/skill/canopy/SKILL.md +1 -1
  23. package/dist/{skill-h7QlCMhc.mjs → skill-CW0QSbAJ.mjs} +2 -2
  24. package/dist/templates/static/AGENTS.md +1 -1
  25. package/dist/templates/static/main.js +1 -1
  26. package/dist/templates/worker/AGENTS.md +11 -9
  27. package/dist/templates/worker/worker/index.ts +27 -4
  28. package/dist/{token-yuwQTQB8.mjs → token-BeF9zczg.mjs} +2 -2
  29. package/dist/{ui-D1DXoDN1.mjs → ui-DThlyHgo.mjs} +28 -36
  30. package/dist/{upgrade-sXoqYLRu.mjs → upgrade-CGz1yzsW.mjs} +3 -3
  31. package/dist/usage-DQpH7A3X.mjs +79 -0
  32. package/package.json +4 -3
  33. package/dist/dev-BYv6BmcI.mjs +0 -2
  34. package/dist/local-B3dyT2fh.mjs +0 -563
  35. package/dist/login-DdNc9OjU.mjs +0 -173
  36. package/dist/project-DKbgJACM.mjs +0 -54
  37. package/dist/{version-check-CoWgQs2h.mjs → version-check-Cuh6HaLX.mjs} +1 -1
@@ -0,0 +1,254 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ //#region src/profiles.ts
5
+ const BAKED_PROFILES = [
6
+ {
7
+ id: "main",
8
+ label: "main (canopy.website)",
9
+ cloud: "cloudflare",
10
+ apiUrl: "https://api.canopy.website",
11
+ projectDomain: "canopy.website"
12
+ },
13
+ {
14
+ id: "staging",
15
+ label: "staging (thanarie.dev)",
16
+ cloud: "cloudflare",
17
+ apiUrl: "https://api.thanarie.dev",
18
+ projectDomain: "thanarie.dev"
19
+ },
20
+ {
21
+ id: "gcp-pilot",
22
+ label: "gcp-pilot (canopy.monks.zone)",
23
+ cloud: "gcp",
24
+ apiUrl: "https://api.canopy.monks.zone",
25
+ projectDomain: "canopy.monks.zone"
26
+ }
27
+ ];
28
+ const DEFAULT_PROFILE_ID = "main";
29
+ const byId = new Map(BAKED_PROFILES.map((p) => [p.id, p]));
30
+ function isProfileId(value) {
31
+ return byId.has(value);
32
+ }
33
+ function getBakedProfile(id) {
34
+ return byId.get(id);
35
+ }
36
+ /** Map a legacy/env apiUrl back to a baked profile when possible. */
37
+ function profileIdForApiUrl(apiUrl) {
38
+ const normalized = apiUrl.replace(/\/$/, "");
39
+ for (const profile of BAKED_PROFILES) if (profile.apiUrl === normalized) return profile.id;
40
+ if (normalized.includes("monks.workers.dev") || normalized.includes("canopy.website")) return "main";
41
+ if (normalized.includes("arjan-van-wijk.workers.dev") || normalized.includes("thanarie.dev")) return "staging";
42
+ if (normalized.includes("run.app") || normalized.includes("canopy.monks.zone")) return "gcp-pilot";
43
+ }
44
+ //#endregion
45
+ //#region src/config.ts
46
+ /**
47
+ * CLI config at ~/.canopy/config.json — active profile + per-site tokens
48
+ * (docs/10-cli-sites.md D17). Baked apiUrl/projectDomain live in profiles.ts.
49
+ *
50
+ * Env overrides: CANOPY_PROFILE, CANOPY_API_URL, CANOPY_OIDC_ISSUER,
51
+ * CANOPY_OIDC_CLIENT_ID, CANOPY_PROJECT_DOMAIN, CANOPY_PROJECT_PROTOCOL.
52
+ */
53
+ const DEFAULT_ISSUER = "https://s4capital.onelogin.com/oidc/2";
54
+ const DEFAULT_CLI_CLIENT_ID = "b69755d0-6678-013f-b5ce-09fe44a57aa0180501";
55
+ const configDir = join(homedir(), ".canopy");
56
+ const configPath = join(configDir, "config.json");
57
+ function readRaw() {
58
+ try {
59
+ return JSON.parse(readFileSync(configPath, "utf8"));
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+ /** Migrate legacy flat `{ apiUrl, token }` into `profiles` + `activeProfile`. */
65
+ function migrateConfigFile(stored) {
66
+ if (stored.profiles && Object.keys(stored.profiles).length > 0) return stored;
67
+ if (!stored.token && !stored.apiUrl) return stored;
68
+ const id = (stored.activeProfile && isProfileId(stored.activeProfile) ? stored.activeProfile : void 0) ?? (stored.apiUrl ? profileIdForApiUrl(stored.apiUrl) : void 0) ?? "main";
69
+ return {
70
+ activeProfile: id,
71
+ profiles: { [id]: {
72
+ token: stored.token,
73
+ tokenExpiresAt: stored.tokenExpiresAt,
74
+ email: stored.email
75
+ } },
76
+ issuer: stored.issuer,
77
+ cliClientId: stored.cliClientId
78
+ };
79
+ }
80
+ function readConfigFile() {
81
+ const raw = readRaw();
82
+ const migrated = migrateConfigFile(raw);
83
+ if (raw.token && !raw.profiles) writeConfigFile(migrated);
84
+ return migrated;
85
+ }
86
+ function writeConfigFile(config) {
87
+ mkdirSync(configDir, { recursive: true });
88
+ const clean = {
89
+ activeProfile: config.activeProfile,
90
+ profiles: config.profiles,
91
+ issuer: config.issuer,
92
+ cliClientId: config.cliClientId
93
+ };
94
+ writeFileSync(configPath, `${JSON.stringify(clean, null, 2)}\n`);
95
+ chmodSync(configPath, 384);
96
+ }
97
+ /**
98
+ * Resolve which site this invocation talks to.
99
+ * Precedence: --profile / CANOPY_PROFILE → canopy.json → activeProfile → main.
100
+ * Then CANOPY_API_URL / domain envs may override the baked URL.
101
+ */
102
+ function resolveConfig(opts = {}) {
103
+ const file = readConfigFile();
104
+ const envProfile = process.env["CANOPY_PROFILE"]?.trim();
105
+ const requested = opts.profileFlag?.trim() || envProfile || opts.projectProfile?.trim() || file.activeProfile;
106
+ const profileId = requested && isProfileId(requested) ? requested : requested ? void 0 : DEFAULT_PROFILE_ID;
107
+ const baked = profileId ? getBakedProfile(profileId) : void 0;
108
+ const auth = profileId ? file.profiles?.[profileId] : void 0;
109
+ const envApi = process.env["CANOPY_API_URL"];
110
+ const envDomain = process.env["CANOPY_PROJECT_DOMAIN"];
111
+ const envProto = process.env["CANOPY_PROJECT_PROTOCOL"]?.trim();
112
+ const apiUrl = envApi ?? baked?.apiUrl ?? getBakedProfile("main").apiUrl;
113
+ const projectDomain = envDomain !== void 0 ? envDomain.trim() || void 0 : baked?.projectDomain ?? void 0;
114
+ const projectProtocol = (() => {
115
+ if (envProto === "http" || envProto === "https") return envProto;
116
+ return baked?.projectProtocol;
117
+ })();
118
+ return {
119
+ profileId: profileId ?? profileIdForApiUrl(apiUrl),
120
+ apiUrl,
121
+ projectDomain,
122
+ projectProtocol,
123
+ cloud: baked?.cloud,
124
+ issuer: process.env["CANOPY_OIDC_ISSUER"] ?? file.issuer ?? DEFAULT_ISSUER,
125
+ cliClientId: process.env["CANOPY_OIDC_CLIENT_ID"] ?? file.cliClientId ?? DEFAULT_CLI_CLIENT_ID,
126
+ token: auth?.token,
127
+ tokenExpiresAt: auth?.tokenExpiresAt,
128
+ email: auth?.email,
129
+ envOverride: envApi !== void 0 || envDomain !== void 0,
130
+ projectProfile: opts.projectProfile
131
+ };
132
+ }
133
+ function listBakedProfiles() {
134
+ return BAKED_PROFILES;
135
+ }
136
+ function setActiveProfile(id) {
137
+ writeConfigFile({
138
+ ...readConfigFile(),
139
+ activeProfile: id
140
+ });
141
+ }
142
+ function upsertProfileAuth(id, auth) {
143
+ const file = readConfigFile();
144
+ writeConfigFile({
145
+ ...file,
146
+ activeProfile: file.activeProfile ?? id,
147
+ profiles: {
148
+ ...file.profiles,
149
+ [id]: {
150
+ ...file.profiles?.[id],
151
+ ...auth
152
+ }
153
+ }
154
+ });
155
+ }
156
+ function clearAllProfileTokens() {
157
+ writeConfigFile({
158
+ ...readConfigFile(),
159
+ profiles: {}
160
+ });
161
+ }
162
+ //#endregion
163
+ //#region src/project.ts
164
+ /**
165
+ * Which project is a command about? Every project-scoped command resolves it
166
+ * the same way: `--project <slug>` wins, otherwise canopy.json in the target
167
+ * directory (the optional [dir] positional, defaulting to the cwd).
168
+ */
169
+ /** The wildcard apex projects are served under (`slug.<domain>`). */
170
+ function projectUrl(slug) {
171
+ const config = siteConfig();
172
+ const apex = config.projectDomain?.trim();
173
+ if (apex) return `${config.projectProtocol ?? (apex.includes("sslip.io") ? "http" : "https")}://${slug}.${apex.replace(/^\./, "")}`;
174
+ const apiHost = new URL(config.apiUrl).hostname;
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
+ return `https://${slug}.${apiHost.split(".").slice(-2).join(".")}`;
177
+ }
178
+ /** canopy.json parsed if present; undefined when the file is missing. */
179
+ function tryReadCanopyJson(dir) {
180
+ let raw;
181
+ try {
182
+ raw = readFileSync(join(dir, "canopy.json"), "utf8");
183
+ } catch {
184
+ return;
185
+ }
186
+ return JSON.parse(raw);
187
+ }
188
+ function readCanopyJson(dir) {
189
+ const config = tryReadCanopyJson(dir);
190
+ if (config === void 0) throw new Error(`No canopy.json in ${dir} — run from a project created by \`canopy create\`, add one ({"slug": "<slug>", "dist": "dist"}), or pass --project <slug>.`);
191
+ if (!config.slug) throw new Error("canopy.json is missing \"slug\".");
192
+ return config;
193
+ }
194
+ /** For commands scoped to a project but not to a directory (db, rollback). */
195
+ const projectFlagArgs = { project: {
196
+ type: "string",
197
+ description: "Project slug (overrides canopy.json in the target directory)"
198
+ } };
199
+ /** For commands that operate on a project directory (dev, deploy, open). */
200
+ const projectDirArgs = {
201
+ dir: {
202
+ type: "positional",
203
+ description: "Project directory (defaults to the current directory)",
204
+ required: false
205
+ },
206
+ ...projectFlagArgs
207
+ };
208
+ /** Resolve the target directory and project slug from the shared args. */
209
+ function resolveProject(args) {
210
+ const dir = resolve(args.dir ?? ".");
211
+ setProjectDirForPin(dir);
212
+ return {
213
+ dir,
214
+ slug: args.project ?? readCanopyJson(dir).slug
215
+ };
216
+ }
217
+ //#endregion
218
+ //#region src/site.ts
219
+ /**
220
+ * Per-invocation site resolution (docs/10-cli-sites.md D17).
221
+ * Set once from global --profile / CANOPY_PROFILE + optional canopy.json pin.
222
+ */
223
+ let invocationProfile;
224
+ let projectDirForPin;
225
+ let mismatchNoticed = false;
226
+ function setInvocationProfile(profile) {
227
+ invocationProfile = profile?.trim() || void 0;
228
+ }
229
+ function setProjectDirForPin(dir) {
230
+ projectDirForPin = dir;
231
+ }
232
+ function projectProfileFromCwd() {
233
+ return tryReadCanopyJson(projectDirForPin ?? ".")?.profile;
234
+ }
235
+ /** Resolved site for this process (cached per call args — cheap). */
236
+ function siteConfig() {
237
+ const projectProfile = projectProfileFromCwd();
238
+ const config = resolveConfig({
239
+ profileFlag: invocationProfile,
240
+ projectProfile
241
+ });
242
+ if (!mismatchNoticed && projectProfile && config.profileId && projectProfile !== config.profileId && (invocationProfile || process.env["CANOPY_PROFILE"])) {
243
+ mismatchNoticed = true;
244
+ console.error(`note: canopy.json pins profile "${projectProfile}" but this command uses "${config.profileId}"`);
245
+ }
246
+ return config;
247
+ }
248
+ /** One-line context for command headers. */
249
+ function siteHeaderLine(cliVersion) {
250
+ const c = siteConfig();
251
+ return `canopy ${cliVersion} · ${c.envOverride || !c.profileId ? c.apiUrl : `${c.profileId} (${c.projectDomain ?? new URL(c.apiUrl).hostname})`}`;
252
+ }
253
+ //#endregion
254
+ export { projectFlagArgs as a, tryReadCanopyJson as c, readConfigFile as d, setActiveProfile as f, isProfileId as g, getBakedProfile as h, projectDirArgs as i, clearAllProfileTokens as l, writeConfigFile as m, siteConfig as n, projectUrl as o, upsertProfileAuth as p, siteHeaderLine as r, resolveProject as s, setInvocationProfile as t, listBakedProfiles as u };
@@ -99,7 +99,7 @@ truth and never go stale.
99
99
  Both `canopy create` and `canopy init` scaffold from the same three templates.
100
100
  **`static` is the default, but reach
101
101
  for it only when the app is genuinely trivial** — a page or two with light SDK
102
- use. Its SDK is served untyped from the platform (`/_api/v1/sdk.js`): no
102
+ use. Its SDK is served untyped from the platform (`/_api/data/v1/sdk.js`): no
103
103
  autocomplete, no type-checking, mistakes only show up in the browser.
104
104
 
105
105
  Prefer **`spa`** the moment either is true — which covers most real projects:
@@ -1,5 +1,5 @@
1
- import { t as version } from "./package-Bnu23N0A.mjs";
2
- import { n as reportErrors } from "./ui-D1DXoDN1.mjs";
1
+ import { t as version } from "./package-CHoZKzaw.mjs";
2
+ import { n as reportErrors } from "./ui-DThlyHgo.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { join, relative } from "node:path";
@@ -21,7 +21,7 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
21
21
 
22
22
  - Plain html/css/js in this directory — no bundler, no install step. The
23
23
  Canopy SDK is imported straight from the platform, so it always matches the
24
- API version: `import { createCanopy } from "/_api/v1/sdk.js";`
24
+ API version: `import { createCanopy } from "/_api/data/v1/sdk.js";`
25
25
  - `canopy dev` — local dev server; its `/_api` proxies to the real platform.
26
26
  That is LIVE project data: read freely, but take a `canopy db snapshot`
27
27
  before risky data changes.
@@ -1,7 +1,7 @@
1
1
  // The Canopy SDK is served by the platform itself — nothing to install.
2
2
  // Auth (auth), data (db), files (blob) and AI (ai) are all live — see
3
3
  // AGENTS.md for how to get the full SDK reference.
4
- import { createCanopy } from "/_api/v1/sdk.js";
4
+ import { createCanopy } from "/_api/data/v1/sdk.js";
5
5
 
6
6
  const canopy = createCanopy();
7
7
 
@@ -5,9 +5,8 @@ A Vite + React single-page app plus a per-project backend Worker on
5
5
  projects. Everything is served behind company login; there is no
6
6
  infrastructure to manage and none should be added.
7
7
 
8
- > **Phase 2 preview:** per-project Workers are not deployable yet.
9
- > `canopy deploy` publishes the frontend only; `worker/` is picked up
10
- > automatically once the platform supports it.
8
+ > On GCP, `canopy deploy` publishes the frontend **and** the `worker/`
9
+ > backend (Cloud Run). Cloudflare WfP upload is still Phase 2.
11
10
 
12
11
  ## Canopy skill
13
12
 
@@ -27,10 +26,11 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
27
26
  - Frontend: a standard Vite + React + TypeScript app in `src/`; `npm install`
28
27
  once, then develop with `canopy dev` (it runs Vite with this project's
29
28
  `vite.config.ts` and proxies `/_api` to the platform).
30
- - Backend: Hono-style handlers in `worker/index.ts`, serving `/api/*`. It runs
31
- behind the Canopy gateway (requests arrive authenticated) and reaches the
32
- platform through a service binding — same SDK, same budgets and data
33
- isolation as the browser.
29
+ - Backend: Hono-style handlers in `worker/index.ts`, serving `/api/*`, plus an
30
+ optional `scheduled(event, env)` for dashboard Schedules (switch on
31
+ `event.name`). It runs behind the Canopy gateway (HTTP requests arrive
32
+ authenticated) and reaches the platform through a service binding — same
33
+ SDK, same budgets and data isolation as the browser.
34
34
  - The SDK comes from npm (`@mediamonks/canopy`) — the browser's shared
35
35
  instance lives in `src/canopy.ts`.
36
36
  - `canopy dev` uses LIVE project data: read freely, but take a
@@ -44,8 +44,10 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
44
44
 
45
45
  - Do not add auth — the platform authenticates every request before it
46
46
  reaches this app or the Worker.
47
- - Do not store secrets anywhere in this project — platform access goes
47
+ - Do not store secrets in the repo or SPA. Set them in the Canopy dashboard
48
+ (Settings → Secrets); the Worker reads them as `c.env.SECRET_NAME`
49
+ (Workers-shaped bindings — not `process.env`). Platform access still goes
48
50
  through the Canopy SDK (`canopy.auth`, `canopy.db`, `canopy.blob`,
49
51
  `canopy.ai`) in both the browser and the Worker.
50
52
  - The Worker is the only backend: no SSR, no long-running processes, no
51
- databases outside `canopy.db`.
53
+ databases outside `canopy.db` / `c.env.DB`.
@@ -4,9 +4,10 @@
4
4
  * platform (db/blob/ai) through the CANOPY service binding, so budgets and
5
5
  * data isolation still apply.
6
6
  *
7
- * NOTE: per-project Workers are a Canopy Phase 2 capability. `canopy deploy`
8
- * does not upload this Worker yet — the frontend deploys like any SPA, and
9
- * this directory is picked up automatically once the platform supports it.
7
+ * On GCP, `canopy deploy` bundles this file and rolls a per-project Cloud Run
8
+ * service (docs/05-gcp-plan.md G6). Routes under `/api/*` reach this Worker;
9
+ * everything else serves the SPA. Dashboard schedules call `scheduled()`
10
+ * (docs/07-schedules.md).
10
11
  */
11
12
 
12
13
  import { createCanopy } from "@mediamonks/canopy";
@@ -15,6 +16,15 @@ import { Hono } from "hono";
15
16
  interface Env {
16
17
  // Service binding to the platform API (canopy-api), scoped to this project.
17
18
  CANOPY: { fetch: typeof fetch };
19
+ // Dashboard secrets (Settings → Secrets) land as Workers-shaped bindings:
20
+ // MY_API_KEY?: string;
21
+ }
22
+
23
+ interface ScheduledEvent {
24
+ name: string;
25
+ cron: string;
26
+ scheduledTime: number;
27
+ type: "scheduled";
18
28
  }
19
29
 
20
30
  const app = new Hono<{ Bindings: Env }>();
@@ -26,4 +36,17 @@ app.get("/api/hello", async (c) => {
26
36
  return c.json({ message: `Hello from the backend, ${user.name}!` });
27
37
  });
28
38
 
29
- export default app;
39
+ export default {
40
+ fetch: app.fetch.bind(app),
41
+
42
+ // Settings → Schedules: switch on event.name (not the cron string).
43
+ async scheduled(event: ScheduledEvent, _env: Env) {
44
+ switch (event.name) {
45
+ // case "daily-digest":
46
+ // await doWork(_env);
47
+ // break;
48
+ default:
49
+ console.warn("unhandled schedule", event.name);
50
+ }
51
+ },
52
+ };
@@ -1,5 +1,5 @@
1
- import { n as reportErrors, s as apiRequest } from "./ui-D1DXoDN1.mjs";
2
- import { i as resolveProject, n as projectFlagArgs, t as projectDirArgs } from "./project-DKbgJACM.mjs";
1
+ import { a as projectFlagArgs, i as projectDirArgs, s as resolveProject } from "./site-BJjoePQS.mjs";
2
+ import { c as apiRequest, n as reportErrors } from "./ui-DThlyHgo.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  const tokenCommand = defineCommand({
5
5
  meta: {
@@ -1,37 +1,6 @@
1
+ import { n as siteConfig } from "./site-BJjoePQS.mjs";
1
2
  import { spawn } from "node:child_process";
2
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
3
  import { cancel, isCancel } from "@clack/prompts";
6
- //#region src/config.ts
7
- /**
8
- * CLI config at ~/.canopy/config.json — platform endpoints and the current
9
- * CLI token. Env overrides: CANOPY_API_URL, CANOPY_OIDC_ISSUER,
10
- * CANOPY_OIDC_CLIENT_ID.
11
- */
12
- const DEFAULT_API_URL = "https://canopy-api.arjan-van-wijk.workers.dev";
13
- const DEFAULT_ISSUER = "https://s4capital.onelogin.com/oidc/2";
14
- const DEFAULT_CLI_CLIENT_ID = "b69755d0-6678-013f-b5ce-09fe44a57aa0180501";
15
- const configDir = join(homedir(), ".canopy");
16
- const configPath = join(configDir, "config.json");
17
- function readConfig() {
18
- let stored = {};
19
- try {
20
- stored = JSON.parse(readFileSync(configPath, "utf8"));
21
- } catch {}
22
- return {
23
- ...stored,
24
- apiUrl: process.env["CANOPY_API_URL"] ?? stored.apiUrl ?? DEFAULT_API_URL,
25
- issuer: process.env["CANOPY_OIDC_ISSUER"] ?? stored.issuer ?? DEFAULT_ISSUER,
26
- cliClientId: process.env["CANOPY_OIDC_CLIENT_ID"] ?? stored.cliClientId ?? DEFAULT_CLI_CLIENT_ID
27
- };
28
- }
29
- function writeConfig(config) {
30
- mkdirSync(configDir, { recursive: true });
31
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
32
- chmodSync(configPath, 384);
33
- }
34
- //#endregion
35
4
  //#region src/api.ts
36
5
  /**
37
6
  * Authenticated requests to the Canopy control plane. Credentials, in order:
@@ -81,6 +50,10 @@ const ERROR_COPY = {
81
50
  why: "the AI service is temporarily unavailable",
82
51
  fix: "This is usually transient — wait a moment and try again."
83
52
  },
53
+ not_supported_on_target: {
54
+ why: "this Canopy deployment's cloud target doesn't support that feature",
55
+ fix: "Per-project workers, schedules and secrets are capability-gated per target — remove the unsupported part (e.g. the worker bundle) or run against a target that supports it."
56
+ },
84
57
  deploy_too_large: {
85
58
  why: "the deploy bundle exceeds the 25 MB limit",
86
59
  fix: "Trim large assets from the build output (or move them to blob storage), then redeploy."
@@ -190,7 +163,7 @@ async function withContext(request, context) {
190
163
  }
191
164
  /** Resolve the bearer token: CI deploy token first (D11), then the login token. */
192
165
  function credentials() {
193
- const config = readConfig();
166
+ const config = siteConfig();
194
167
  const deployToken = process.env["CANOPY_DEPLOY_TOKEN"];
195
168
  const token = deployToken || config.token;
196
169
  if (!token) throw new Error("Not logged in — run `canopy login` first.");
@@ -214,7 +187,7 @@ async function fail(response, isDeployToken, method, path) {
214
187
  async function apiRequest(method, path, body, rawContentType, headers) {
215
188
  const { apiUrl, token, isDeployToken } = credentials();
216
189
  const isForm = body instanceof FormData;
217
- const response = await fetch(`${apiUrl}/v1${path}`, {
190
+ const response = await fetch(`${apiUrl}/control/v1${path}`, {
218
191
  method,
219
192
  headers: {
220
193
  authorization: `Bearer ${token}`,
@@ -229,10 +202,29 @@ async function apiRequest(method, path, body, rawContentType, headers) {
229
202
  /** Authenticated GET returning raw bytes (e.g. a deploy zip). */
230
203
  async function apiDownload(path) {
231
204
  const { apiUrl, token, isDeployToken } = credentials();
232
- const response = await fetch(`${apiUrl}/v1${path}`, { headers: { authorization: `Bearer ${token}` } });
205
+ const response = await fetch(`${apiUrl}/control/v1${path}`, { headers: { authorization: `Bearer ${token}` } });
233
206
  if (!response.ok) return fail(response, isDeployToken, "GET", path);
234
207
  return Buffer.from(await response.arrayBuffer());
235
208
  }
209
+ /**
210
+ * Data-plane call (db safety: export/snapshots/restore). The CLI token is
211
+ * unscoped; the target project rides as X-Canopy-Project and the api. gateway
212
+ * re-vouches it (docs/10-cli-sites.md D17).
213
+ */
214
+ async function apiDataRequest(method, path, projectSlug, body, rawContentType) {
215
+ const { apiUrl, token, isDeployToken } = credentials();
216
+ const response = await fetch(`${apiUrl}/data/v1${path}`, {
217
+ method,
218
+ headers: {
219
+ authorization: `Bearer ${token}`,
220
+ "x-canopy-project": projectSlug,
221
+ ...body === void 0 ? {} : { "content-type": rawContentType ?? "application/json" }
222
+ },
223
+ body: body === void 0 ? void 0 : rawContentType !== void 0 ? body : JSON.stringify(body)
224
+ });
225
+ if (!response.ok) return fail(response, isDeployToken, method, path);
226
+ return await response.json();
227
+ }
236
228
  //#endregion
237
229
  //#region src/ui.ts
238
230
  /**
@@ -305,4 +297,4 @@ function openBrowser(url) {
305
297
  }).on("error", () => {}).unref();
306
298
  }
307
299
  //#endregion
308
- export { ApiError as a, withContext as c, unwrap as i, readConfig as l, reportErrors as n, apiDownload as o, requireTty as r, apiRequest as s, openBrowser as t, writeConfig as u };
300
+ export { ApiError as a, apiRequest as c, unwrap as i, withContext as l, reportErrors as n, apiDataRequest as o, requireTty as r, apiDownload as s, openBrowser as t };
@@ -1,5 +1,5 @@
1
- import { t as PACKAGE_NAME } from "./version-check-CoWgQs2h.mjs";
2
- import { n as reportErrors } from "./ui-D1DXoDN1.mjs";
1
+ import { t as PACKAGE_NAME } from "./version-check-Cuh6HaLX.mjs";
2
+ import { n as reportErrors } from "./ui-DThlyHgo.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-h7QlCMhc.mjs");
115
+ const { skillInstalled } = await import("./skill-CW0QSbAJ.mjs");
116
116
  const refreshSkill = skillInstalled(process.cwd());
117
117
  console.log(`Upgrading via ${command}: ${command} ${cmd.join(" ")}`);
118
118
  await run(command, cmd);
@@ -0,0 +1,79 @@
1
+ import { a as projectFlagArgs, s as resolveProject } from "./site-BJjoePQS.mjs";
2
+ import { c as apiRequest, l as withContext, n as reportErrors } from "./ui-DThlyHgo.mjs";
3
+ import { defineCommand } from "citty";
4
+ //#region src/usage.ts
5
+ /**
6
+ * canopy usage — print a project's AI spend + storage footprint
7
+ * (same data as the dashboard Usage tab / GET /v1/projects/:slug/usage).
8
+ */
9
+ function formatBytes(bytes) {
10
+ if (bytes < 1024) return `${bytes} B`;
11
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
12
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
14
+ }
15
+ function formatUsd(n) {
16
+ if (n === 0) return "$0";
17
+ if (n < 1e-4) return `$${n.toExponential(1)}`;
18
+ if (n < .01) return `$${n.toFixed(4)}`;
19
+ return `$${n.toFixed(2)}`;
20
+ }
21
+ function formatTokens(n) {
22
+ if (n < 1e3) return String(n);
23
+ if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
24
+ return `${(n / 1e6).toFixed(2)}M`;
25
+ }
26
+ function pad(label, width = 12) {
27
+ return label.padEnd(width);
28
+ }
29
+ function renderUsage(slug, usage) {
30
+ const lines = [`${slug} — last ${usage.ai.windowDays ?? 30} days`, ""];
31
+ lines.push("AI");
32
+ if (!usage.ai.available) lines.push(` ${pad("status")}unavailable${usage.ai.reason ? ` (${usage.ai.reason})` : ""}`);
33
+ else {
34
+ lines.push(` ${pad("spend")}${formatUsd(usage.ai.costUsd ?? 0)}`);
35
+ lines.push(` ${pad("requests")}${usage.ai.requests ?? 0}`);
36
+ lines.push(` ${pad("tokens")}${formatTokens(usage.ai.tokensIn ?? 0)} in / ${formatTokens(usage.ai.tokensOut ?? 0)} out`);
37
+ if (usage.ai.byModel?.length) {
38
+ lines.push(" by model");
39
+ for (const row of usage.ai.byModel.slice(0, 8)) lines.push(` ${row.model.padEnd(36)} ${String(row.requests).padStart(4)} ${formatUsd(row.costUsd)}`);
40
+ }
41
+ if (usage.ai.truncated) lines.push(" (truncated — more requests in the window than we summarized)");
42
+ }
43
+ lines.push("");
44
+ lines.push("Storage");
45
+ lines.push(usage.db ? ` ${pad("database")}${formatBytes(usage.db.sizeBytes)} · ${usage.db.tables} tables` : ` ${pad("database")}—`);
46
+ lines.push(usage.blob ? ` ${pad("blobs")}${formatBytes(usage.blob.totalBytes)} · ${usage.blob.objects} objects` : ` ${pad("blobs")}—`);
47
+ const deploys = usage.deploys.count;
48
+ const live = usage.deploys.latestVersion != null ? ` · v${usage.deploys.latestVersion} live` : "";
49
+ lines.push(` ${pad("deploys")}${deploys}${live}`);
50
+ return lines.join("\n");
51
+ }
52
+ const usageCommand = defineCommand({
53
+ meta: {
54
+ name: "usage",
55
+ description: "Show AI spend and storage usage for a project"
56
+ },
57
+ args: {
58
+ ...projectFlagArgs,
59
+ json: {
60
+ type: "boolean",
61
+ description: "Print raw JSON (same shape as the dashboard Usage API)",
62
+ default: false
63
+ }
64
+ },
65
+ run: reportErrors("usage", async ({ args }) => {
66
+ const { slug } = resolveProject(args);
67
+ const { usage } = await withContext(apiRequest("GET", `/projects/${slug}/usage`), { action: `look up usage for "${slug}"` });
68
+ if (args.json) {
69
+ console.log(JSON.stringify({
70
+ slug,
71
+ usage
72
+ }, null, 2));
73
+ return;
74
+ }
75
+ console.log(renderUsage(slug, usage));
76
+ })
77
+ });
78
+ //#endregion
79
+ export { usageCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -15,11 +15,12 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@clack/prompts": "^1.7.0",
18
+ "@electric-sql/pglite": "^0.3.14",
18
19
  "citty": "^0.2.2",
20
+ "esbuild": "^0.28.1",
19
21
  "hono": "^4.8.0",
20
- "miniflare": "^4.20260714.0",
21
22
  "vite-server": "npm:vite@^7",
22
- "@mediamonks/canopy": "0.0.2",
23
+ "@mediamonks/canopy": "0.0.3",
23
24
  "@mediamonks/canopy-data-plane": "0.0.1"
24
25
  },
25
26
  "devDependencies": {
@@ -1,2 +0,0 @@
1
- import { t as devCommand } from "./dev-CuvlJpcO.mjs";
2
- export { devCommand };