@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.
- package/README.md +43 -0
- package/dist/{db-PpaU3V-B.mjs → db-BW1ZolWn.mjs} +8 -11
- package/dist/{deploy-B064KXtr.mjs → deploy-alC2--pZ.mjs} +92 -12
- package/dist/dev-BJ5xEwqW.mjs +2 -0
- package/dist/{dev-CuvlJpcO.mjs → dev-CQQhMOoD.mjs} +22 -24
- 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-UYzmzpz-.mjs → docs-DvSPpSIR.mjs} +8 -7
- package/dist/index.mjs +53 -19
- package/dist/local-C5-GWJcx.mjs +826 -0
- package/dist/login-BO6abf40.mjs +383 -0
- package/dist/{open-CBuZBJlv.mjs → open-DnlYDqrU.mjs} +2 -2
- package/dist/{package-Bnu23N0A.mjs → package-CHoZKzaw.mjs} +1 -1
- package/dist/{projects-CK4DNxgZ.mjs → projects-ijfe7Ffx.mjs} +21 -3
- package/dist/site-BJjoePQS.mjs +254 -0
- package/dist/skill/canopy/SKILL.md +1 -1
- package/dist/{skill-h7QlCMhc.mjs → skill-CW0QSbAJ.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-yuwQTQB8.mjs → token-BeF9zczg.mjs} +2 -2
- package/dist/{ui-D1DXoDN1.mjs → ui-DThlyHgo.mjs} +28 -36
- package/dist/{upgrade-sXoqYLRu.mjs → upgrade-CGz1yzsW.mjs} +3 -3
- package/dist/usage-DQpH7A3X.mjs +79 -0
- package/package.json +4 -3
- package/dist/dev-BYv6BmcI.mjs +0 -2
- package/dist/local-B3dyT2fh.mjs +0 -563
- package/dist/login-DdNc9OjU.mjs +0 -173
- package/dist/project-DKbgJACM.mjs +0 -54
- 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-
|
|
2
|
-
import { n as reportErrors } from "./ui-
|
|
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
|
-
>
|
|
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`.
|
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
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 =
|
|
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,
|
|
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-
|
|
2
|
-
import { n as reportErrors } from "./ui-
|
|
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-
|
|
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.
|
|
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.
|
|
23
|
+
"@mediamonks/canopy": "0.0.3",
|
|
23
24
|
"@mediamonks/canopy-data-plane": "0.0.1"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
package/dist/dev-BYv6BmcI.mjs
DELETED