@mediamonks/canopy-cli 0.4.4 → 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.
- package/README.md +43 -0
- package/dist/{db-Bkq-_qkd.mjs → db-BW1ZolWn.mjs} +9 -12
- package/dist/{deploy-BO_-gK32.mjs → deploy-alC2--pZ.mjs} +92 -13
- package/dist/dev-BJ5xEwqW.mjs +2 -0
- package/dist/{dev-pPVCUaFp.mjs → dev-CQQhMOoD.mjs} +37 -29
- package/dist/docs/ci.md +6 -2
- package/dist/docs/cli.md +8 -3
- package/dist/docs/data.md +22 -4
- package/dist/docs/deploy.md +10 -6
- package/dist/docs/dev.md +10 -3
- package/dist/docs/infrastructure.md +397 -0
- package/dist/docs/static-serving.md +318 -0
- package/dist/docs/troubleshooting.md +14 -0
- package/dist/{docs-C3aOAx_A.mjs → docs-DvSPpSIR.mjs} +8 -8
- package/dist/index.mjs +53 -19
- package/dist/local-C5-GWJcx.mjs +826 -0
- package/dist/login-Bz-rwlFF.mjs +264 -0
- package/dist/{open-xLiGUz_W.mjs → open-DnlYDqrU.mjs} +2 -2
- package/dist/{package-BHlmIdUm.mjs → package-Bi5FfVSP.mjs} +1 -1
- package/dist/{projects-oyjC4ZGb.mjs → projects-ijfe7Ffx.mjs} +21 -4
- package/dist/site-BJjoePQS.mjs +254 -0
- package/dist/skill/canopy/SKILL.md +1 -1
- package/dist/{skill-BsZKAgGI.mjs → skill-IAAa2EkK.mjs} +2 -2
- package/dist/templates/static/AGENTS.md +1 -1
- package/dist/templates/static/main.js +1 -1
- package/dist/templates/worker/AGENTS.md +11 -9
- package/dist/templates/worker/worker/index.ts +27 -4
- package/dist/{token-BXc2klNm.mjs → token-BeF9zczg.mjs} +2 -3
- package/dist/ui-DThlyHgo.mjs +300 -0
- package/dist/{upgrade-nYLjXxPk.mjs → upgrade-DNxR-zh7.mjs} +3 -3
- package/dist/usage-DQpH7A3X.mjs +79 -0
- package/package.json +5 -4
- package/dist/api-BcX_w6Xf.mjs +0 -58
- package/dist/config-C-z7st1X.mjs +0 -33
- package/dist/dev-R6nzDtA-.mjs +0 -2
- package/dist/local-MLfuFVZb.mjs +0 -563
- package/dist/login-Dif9Y32q.mjs +0 -174
- package/dist/project-CpiPLNby.mjs +0 -54
- package/dist/ui-C8VuPzGp.mjs +0 -40
- 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 { i as
|
|
2
|
-
import { n as reportErrors, t as openBrowser } from "./ui-
|
|
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,6 +1,5 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
import {
|
|
3
|
-
import { i as unwrap, n as reportErrors, r as requireTty } from "./ui-C8VuPzGp.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";
|
|
4
3
|
import { defineCommand } from "citty";
|
|
5
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { join, relative, resolve, sep } from "node:path";
|
|
@@ -65,6 +64,16 @@ function scaffoldTemplate(templateId, targetDir, vars) {
|
|
|
65
64
|
* scaffolds ./<slug>/ from the starter template (--no-scaffold to skip, e.g.
|
|
66
65
|
* for an existing directory).
|
|
67
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
|
+
}
|
|
68
77
|
function slugify(input) {
|
|
69
78
|
return input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-+|-+$)/g, "").slice(0, 40);
|
|
70
79
|
}
|
|
@@ -146,6 +155,10 @@ const createCommand = defineCommand({
|
|
|
146
155
|
name,
|
|
147
156
|
slug
|
|
148
157
|
});
|
|
158
|
+
writeCanopyJson(targetDir, {
|
|
159
|
+
...tryReadCanopyJson(targetDir),
|
|
160
|
+
slug
|
|
161
|
+
});
|
|
149
162
|
const steps = [
|
|
150
163
|
`cd ${slug}`,
|
|
151
164
|
...template.needsInstall ? ["npm install"] : [],
|
|
@@ -233,6 +246,10 @@ const initCommand = defineCommand({
|
|
|
233
246
|
name: project.name,
|
|
234
247
|
slug
|
|
235
248
|
});
|
|
249
|
+
writeCanopyJson(targetDir, {
|
|
250
|
+
...tryReadCanopyJson(targetDir),
|
|
251
|
+
slug
|
|
252
|
+
});
|
|
236
253
|
const steps = [
|
|
237
254
|
...rel === "." ? [] : [`cd ${rel}`],
|
|
238
255
|
...template.needsInstall ? ["npm install"] : [],
|
|
@@ -249,7 +266,7 @@ const initCommand = defineCommand({
|
|
|
249
266
|
p.outro(`${rel} is already linked to ${slug}.`);
|
|
250
267
|
return;
|
|
251
268
|
}
|
|
252
|
-
|
|
269
|
+
writeCanopyJson(targetDir, { slug });
|
|
253
270
|
p.note("Add \"dist\"/\"build\" to canopy.json if this app has a build step, then:\ncanopy deploy", `Linked ${rel} to ${slug}`);
|
|
254
271
|
p.outro(`Live URL (after first deploy): ${projectUrl(slug)}`);
|
|
255
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-
|
|
2
|
-
import { n as reportErrors } from "./ui-
|
|
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
|
-
>
|
|
9
|
-
>
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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`.
|