@mediamonks/canopy-cli 0.4.5 → 0.5.0

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-Bz-rwlFF.mjs +264 -0
  18. package/dist/{open-CBuZBJlv.mjs → open-DnlYDqrU.mjs} +2 -2
  19. package/dist/{package-Bnu23N0A.mjs → package-Bi5FfVSP.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-IAAa2EkK.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-DNxR-zh7.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,264 @@
1
+ import { d as readConfigFile, f as setActiveProfile, g as isProfileId, h as getBakedProfile, l as clearAllProfileTokens, m as writeConfigFile, n as siteConfig, p as upsertProfileAuth, u as listBakedProfiles } from "./site-BJjoePQS.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-DThlyHgo.mjs";
3
+ import { defineCommand } from "citty";
4
+ import * as p from "@clack/prompts";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { createServer } from "node:http";
7
+ //#region src/login.ts
8
+ /**
9
+ * canopy login | whoami | logout — browser PKCE flow against OneLogin (public
10
+ * client), then trade the ID token for platform CLI tokens via POST
11
+ * /control/v1/auth/cli-token on every baked site (docs/10-cli-sites.md D17).
12
+ */
13
+ const CALLBACK_PORT = 8976;
14
+ const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
15
+ const LOGIN_TIMEOUT_MS = 300 * 1e3;
16
+ const base64url = (buf) => buf.toString("base64url");
17
+ const whoamiCommand = defineCommand({
18
+ meta: {
19
+ name: "whoami",
20
+ description: "Show the current Canopy login"
21
+ },
22
+ run: reportErrors("whoami", async () => {
23
+ const config = siteConfig();
24
+ if (!config.token) throw new Error("Not logged in — run `canopy login`.");
25
+ console.log(config.email ?? "<unknown email>");
26
+ if (config.cloud) console.log(`Cloud: ${config.cloud}`);
27
+ console.log(`API: ${config.apiUrl}`);
28
+ if (config.tokenExpiresAt !== void 0) {
29
+ const expired = Date.parse(config.tokenExpiresAt) < Date.now();
30
+ console.log(expired ? `Token EXPIRED ${config.tokenExpiresAt} — run \`canopy login\` again.` : `Token expires ${config.tokenExpiresAt}.`);
31
+ }
32
+ })
33
+ });
34
+ const logoutCommand = defineCommand({
35
+ meta: {
36
+ name: "logout",
37
+ description: "Sign out: forget all local CLI tokens"
38
+ },
39
+ run: reportErrors("logout", async () => {
40
+ const file = readConfigFile();
41
+ if (!Object.values(file.profiles ?? {}).some((p) => p?.token)) {
42
+ console.log("Not logged in.");
43
+ return;
44
+ }
45
+ clearAllProfileTokens();
46
+ console.log("Logged out — all site tokens removed from ~/.canopy/config.json.");
47
+ })
48
+ });
49
+ /** Wait for OneLogin to redirect back to the local loopback server. */
50
+ function waitForCallback(expectedState) {
51
+ return new Promise((resolve, reject) => {
52
+ const server = createServer((req, res) => {
53
+ const url = new URL(req.url ?? "/", REDIRECT_URI);
54
+ if (url.pathname !== "/callback") {
55
+ res.writeHead(404).end();
56
+ return;
57
+ }
58
+ const code = url.searchParams.get("code");
59
+ const state = url.searchParams.get("state");
60
+ const ok = Boolean(code) && state === expectedState;
61
+ res.writeHead(ok ? 200 : 400, { "content-type": "text/html" }).end(ok ? "<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>" : "<h1>Login failed</h1><p>Return to the terminal and try again.</p>");
62
+ server.close();
63
+ clearTimeout(timer);
64
+ if (ok) resolve(code);
65
+ else reject(new Error(url.searchParams.get("error_description") ?? "Login rejected"));
66
+ });
67
+ const timer = setTimeout(() => {
68
+ server.close();
69
+ reject(/* @__PURE__ */ new Error("Timed out waiting for the browser login (5 minutes)."));
70
+ }, LOGIN_TIMEOUT_MS);
71
+ server.on("error", (err) => reject(err.message.includes("EADDRINUSE") ? /* @__PURE__ */ new Error(`Port ${CALLBACK_PORT} is in use — close the other process and retry.`) : err));
72
+ server.listen(CALLBACK_PORT, "127.0.0.1");
73
+ });
74
+ }
75
+ async function pickActiveProfile(json, flag) {
76
+ if (flag) {
77
+ if (!isProfileId(flag)) throw new Error(`Unknown profile "${flag}". Choose one of: ${listBakedProfiles().map((p) => p.id).join(", ")}.`);
78
+ return flag;
79
+ }
80
+ const env = process.env["CANOPY_PROFILE"]?.trim();
81
+ if (env) {
82
+ if (!isProfileId(env)) throw new Error(`Unknown CANOPY_PROFILE "${env}".`);
83
+ return env;
84
+ }
85
+ const profiles = listBakedProfiles();
86
+ if (!process.stdin.isTTY || json) throw new Error(`Pass --profile <id> (or CANOPY_PROFILE) when not on a TTY. Options: ${profiles.map((p) => p.id).join(", ")}.`);
87
+ const choice = await p.select({
88
+ message: "Which Canopy site should be your default?",
89
+ options: profiles.map((prof) => ({
90
+ value: prof.id,
91
+ label: prof.label,
92
+ hint: prof.cloud
93
+ })),
94
+ initialValue: "main"
95
+ });
96
+ if (p.isCancel(choice)) {
97
+ p.cancel("Login cancelled.");
98
+ process.exit(1);
99
+ }
100
+ p.note(`Using profile "${choice}". Agents/non-TTY: canopy login --profile ${choice}`, "Active profile");
101
+ return choice;
102
+ }
103
+ async function mintForProfile(id, idToken) {
104
+ const baked = getBakedProfile(id);
105
+ try {
106
+ const cliTokenRes = await fetch(`${baked.apiUrl}/control/v1/auth/cli-token`, {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json" },
109
+ body: JSON.stringify({ id_token: idToken })
110
+ });
111
+ if (!cliTokenRes.ok) return {
112
+ ok: false,
113
+ error: `${cliTokenRes.status} ${await cliTokenRes.text()}`
114
+ };
115
+ const { token, expires_at, user } = await cliTokenRes.json();
116
+ upsertProfileAuth(id, {
117
+ token,
118
+ tokenExpiresAt: expires_at,
119
+ email: user.email
120
+ });
121
+ return {
122
+ ok: true,
123
+ email: user.email,
124
+ expiresAt: expires_at
125
+ };
126
+ } catch (err) {
127
+ return {
128
+ ok: false,
129
+ error: err instanceof Error ? err.message : String(err)
130
+ };
131
+ }
132
+ }
133
+ async function login(json, profileFlag) {
134
+ if (!json) p.intro("canopy login");
135
+ const active = await pickActiveProfile(json, profileFlag);
136
+ const file = readConfigFile();
137
+ const { issuer, cliClientId } = {
138
+ issuer: process.env["CANOPY_OIDC_ISSUER"] ?? file.issuer ?? "https://s4capital.onelogin.com/oidc/2",
139
+ cliClientId: process.env["CANOPY_OIDC_CLIENT_ID"] ?? file.cliClientId ?? "b69755d0-6678-013f-b5ce-09fe44a57aa0180501"
140
+ };
141
+ if (cliClientId.includes("REPLACE")) throw new Error("The CLI's OneLogin app is not configured yet (set CANOPY_OIDC_CLIENT_ID or packages/cli/src/config.ts).");
142
+ writeConfigFile({
143
+ ...file,
144
+ issuer,
145
+ cliClientId,
146
+ activeProfile: active
147
+ });
148
+ const discoveryRes = await fetch(`${issuer}/.well-known/openid-configuration`);
149
+ if (!discoveryRes.ok) throw new Error(`OIDC discovery failed: ${discoveryRes.status}`);
150
+ const discovery = await discoveryRes.json();
151
+ const state = base64url(randomBytes(32));
152
+ const codeVerifier = base64url(randomBytes(32));
153
+ const authorizeUrl = new URL(discovery.authorization_endpoint);
154
+ authorizeUrl.search = new URLSearchParams({
155
+ response_type: "code",
156
+ client_id: cliClientId,
157
+ redirect_uri: REDIRECT_URI,
158
+ scope: "openid profile email",
159
+ state,
160
+ code_challenge: base64url(createHash("sha256").update(codeVerifier).digest()),
161
+ code_challenge_method: "S256"
162
+ }).toString();
163
+ const authorizeHref = authorizeUrl.toString();
164
+ openBrowser(authorizeHref);
165
+ const spin = json ? void 0 : p.spinner();
166
+ if (json) console.log(JSON.stringify({
167
+ status: "waiting",
168
+ url: authorizeHref
169
+ }));
170
+ else {
171
+ p.note(`If nothing opens, visit:\n${authorizeHref}`, "Opening OneLogin in your browser");
172
+ spin?.start("Waiting for you to sign in in the browser…");
173
+ }
174
+ let code;
175
+ try {
176
+ code = await waitForCallback(state);
177
+ } catch (err) {
178
+ spin?.error("Sign-in failed.");
179
+ throw err;
180
+ }
181
+ spin?.stop("Signed in with OneLogin.");
182
+ const tokenRes = await fetch(discovery.token_endpoint, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/x-www-form-urlencoded" },
185
+ body: new URLSearchParams({
186
+ grant_type: "authorization_code",
187
+ client_id: cliClientId,
188
+ code,
189
+ redirect_uri: REDIRECT_URI,
190
+ code_verifier: codeVerifier
191
+ })
192
+ });
193
+ if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
194
+ const { id_token: idToken } = await tokenRes.json();
195
+ if (!idToken) throw new Error("OneLogin returned no id_token.");
196
+ const mintSpin = json ? void 0 : p.spinner();
197
+ mintSpin?.start("Minting CLI tokens for each site…");
198
+ const results = [];
199
+ for (const prof of listBakedProfiles()) {
200
+ const result = await mintForProfile(prof.id, idToken);
201
+ results.push({
202
+ id: prof.id,
203
+ ok: result.ok,
204
+ detail: result.ok ? `expires ${result.expiresAt}` : result.error
205
+ });
206
+ }
207
+ mintSpin?.stop("Token mint finished.");
208
+ if (!results.find((r) => r.id === active)?.ok) {
209
+ const summary = results.map((r) => `${r.id} ${r.ok ? "✓" : "✗"}`).join(" ");
210
+ throw new Error(`Could not mint a token for active profile "${active}". ${summary}. Re-run \`canopy login\`.`);
211
+ }
212
+ setActiveProfile(active);
213
+ const email = readConfigFile().profiles?.[active]?.email;
214
+ const summary = results.map((r) => `${r.id} ${r.ok ? "✓" : `✗ (${r.detail})`}`).join("\n");
215
+ if (json) console.log(JSON.stringify({
216
+ status: "ok",
217
+ email,
218
+ activeProfile: active,
219
+ profiles: Object.fromEntries(results.map((r) => [r.id, {
220
+ ok: r.ok,
221
+ detail: r.detail
222
+ }]))
223
+ }));
224
+ else {
225
+ p.note(summary, "Sites");
226
+ p.outro(`Logged in as ${email ?? "unknown"} — default profile "${active}".`);
227
+ for (const r of results.filter((x) => !x.ok)) p.log.warn(`${r.id} failed — re-run \`canopy login\` to retry all sites.`);
228
+ }
229
+ }
230
+ const loginCommand = defineCommand({
231
+ meta: {
232
+ name: "login",
233
+ description: "Sign in to Canopy via OneLogin"
234
+ },
235
+ args: {
236
+ json: {
237
+ type: "boolean",
238
+ default: false,
239
+ description: "Emit machine-readable status to stdout (for agents/CI)"
240
+ },
241
+ profile: {
242
+ type: "string",
243
+ description: "Active default profile (main | staging | gcp-pilot); required non-TTY"
244
+ }
245
+ },
246
+ run: reportErrors("login", async ({ args }) => {
247
+ const json = args.json;
248
+ try {
249
+ await login(json, args.profile);
250
+ } catch (err) {
251
+ if (json) {
252
+ console.log(JSON.stringify({
253
+ status: "error",
254
+ message: err instanceof Error ? err.message : String(err)
255
+ }));
256
+ process.exitCode = 1;
257
+ return;
258
+ }
259
+ throw err;
260
+ }
261
+ })
262
+ });
263
+ //#endregion
264
+ export { loginCommand, logoutCommand, whoamiCommand };
@@ -1,5 +1,5 @@
1
- import { n as reportErrors, t as openBrowser } from "./ui-D1DXoDN1.mjs";
2
- import { i as resolveProject, r as projectUrl, t as projectDirArgs } from "./project-DKbgJACM.mjs";
1
+ import { i as projectDirArgs, o as projectUrl, s as resolveProject } from "./site-BJjoePQS.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-DThlyHgo.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.4.5";
2
+ var version = "0.5.0";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,5 +1,5 @@
1
- import { i as unwrap, n as reportErrors, r as requireTty, s as apiRequest } from "./ui-D1DXoDN1.mjs";
2
- import { a as tryReadCanopyJson, r as projectUrl } from "./project-DKbgJACM.mjs";
1
+ import { c as tryReadCanopyJson, n as siteConfig, o as projectUrl } from "./site-BJjoePQS.mjs";
2
+ import { c as apiRequest, i as unwrap, n as reportErrors, r as requireTty } from "./ui-DThlyHgo.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";
@@ -64,6 +64,16 @@ function scaffoldTemplate(templateId, targetDir, vars) {
64
64
  * scaffolds ./<slug>/ from the starter template (--no-scaffold to skip, e.g.
65
65
  * for an existing directory).
66
66
  */
67
+ /** Write/update canopy.json with the effective profile pin (D17). */
68
+ function writeCanopyJson(dir, fields) {
69
+ const profile = siteConfig().profileId;
70
+ const next = {
71
+ ...tryReadCanopyJson(dir) ?? {},
72
+ ...fields,
73
+ ...profile ? { profile } : {}
74
+ };
75
+ writeFileSync(join(dir, "canopy.json"), `${JSON.stringify(next, null, 2)}\n`);
76
+ }
67
77
  function slugify(input) {
68
78
  return input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-+|-+$)/g, "").slice(0, 40);
69
79
  }
@@ -145,6 +155,10 @@ const createCommand = defineCommand({
145
155
  name,
146
156
  slug
147
157
  });
158
+ writeCanopyJson(targetDir, {
159
+ ...tryReadCanopyJson(targetDir),
160
+ slug
161
+ });
148
162
  const steps = [
149
163
  `cd ${slug}`,
150
164
  ...template.needsInstall ? ["npm install"] : [],
@@ -232,6 +246,10 @@ const initCommand = defineCommand({
232
246
  name: project.name,
233
247
  slug
234
248
  });
249
+ writeCanopyJson(targetDir, {
250
+ ...tryReadCanopyJson(targetDir),
251
+ slug
252
+ });
235
253
  const steps = [
236
254
  ...rel === "." ? [] : [`cd ${rel}`],
237
255
  ...template.needsInstall ? ["npm install"] : [],
@@ -248,7 +266,7 @@ const initCommand = defineCommand({
248
266
  p.outro(`${rel} is already linked to ${slug}.`);
249
267
  return;
250
268
  }
251
- writeFileSync(join(targetDir, "canopy.json"), `${JSON.stringify({ slug }, null, 2)}\n`);
269
+ writeCanopyJson(targetDir, { slug });
252
270
  p.note("Add \"dist\"/\"build\" to canopy.json if this app has a build step, then:\ncanopy deploy", `Linked ${rel} to ${slug}`);
253
271
  p.outro(`Live URL (after first deploy): ${projectUrl(slug)}`);
254
272
  })
@@ -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-Bi5FfVSP.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`.