@mediamonks/canopy-cli 0.0.1 → 0.1.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 (55) hide show
  1. package/dist/api-CyJ-wBZh.mjs +40 -0
  2. package/dist/config-C-z7st1X.mjs +33 -0
  3. package/dist/{db-BBcgT1md.mjs → db-CtUG4vfG.mjs} +3 -3
  4. package/dist/{deploy-BfhAlb91.mjs → deploy-N88l4Kt3.mjs} +7 -6
  5. package/dist/{dev-BB7CwqoI.mjs → dev-DTGW0lm0.mjs} +6 -5
  6. package/dist/dev-D_GlUULk.mjs +2 -0
  7. package/dist/index.mjs +24 -14
  8. package/dist/{local-DhufX0eJ.mjs → local-951zc9qJ.mjs} +2 -2
  9. package/dist/{login-DrXKOEmr.mjs → login-DsWvYc8C.mjs} +2 -1
  10. package/dist/{open-DFWvrLFc.mjs → open-xLiGUz_W.mjs} +2 -2
  11. package/dist/{project-CrrpLEPs.mjs → project-CpiPLNby.mjs} +1 -1
  12. package/dist/projects-DJT_mJDR.mjs +228 -0
  13. package/dist/templates/spa/.github/workflows/canopy-deploy.yml +34 -0
  14. package/dist/templates/spa/AGENTS.md +43 -0
  15. package/dist/templates/spa/README.md +14 -0
  16. package/dist/templates/spa/_gitignore +4 -0
  17. package/dist/templates/spa/canopy.json +5 -0
  18. package/dist/templates/spa/index.html +12 -0
  19. package/dist/templates/spa/package.json +21 -0
  20. package/dist/templates/spa/src/app.tsx +25 -0
  21. package/dist/templates/spa/src/canopy.ts +5 -0
  22. package/dist/templates/spa/src/main.tsx +10 -0
  23. package/dist/templates/spa/src/style.css +29 -0
  24. package/dist/templates/spa/tsconfig.json +16 -0
  25. package/dist/templates/spa/vite.config.ts +7 -0
  26. package/dist/templates/static/AGENTS.md +41 -0
  27. package/dist/templates/static/README.md +9 -0
  28. package/dist/templates/static/_gitignore +2 -0
  29. package/dist/templates/static/canopy.json +4 -0
  30. package/dist/templates/static/index.html +21 -0
  31. package/dist/templates/static/main.js +9 -0
  32. package/dist/templates/static/style.css +29 -0
  33. package/dist/templates/worker/.github/workflows/canopy-deploy.yml +34 -0
  34. package/dist/templates/worker/AGENTS.md +51 -0
  35. package/dist/templates/worker/README.md +18 -0
  36. package/dist/templates/worker/_gitignore +4 -0
  37. package/dist/templates/worker/canopy.json +6 -0
  38. package/dist/templates/worker/index.html +12 -0
  39. package/dist/templates/worker/package.json +22 -0
  40. package/dist/templates/worker/src/app.tsx +25 -0
  41. package/dist/templates/worker/src/canopy.ts +5 -0
  42. package/dist/templates/worker/src/main.tsx +10 -0
  43. package/dist/templates/worker/src/style.css +29 -0
  44. package/dist/templates/worker/tsconfig.json +16 -0
  45. package/dist/templates/worker/vite.config.ts +7 -0
  46. package/dist/templates/worker/worker/index.ts +29 -0
  47. package/dist/token-rTD9jxPZ.mjs +75 -0
  48. package/dist/ui-C8VuPzGp.mjs +40 -0
  49. package/dist/upgrade-TbMOjkLY.mjs +92 -0
  50. package/dist/version-check-CoWgQs2h.mjs +128 -0
  51. package/package.json +4 -4
  52. package/dist/api-BEY4O4ap.mjs +0 -36
  53. package/dist/dev-Bvy21srH.mjs +0 -2
  54. package/dist/projects-f_KocDae.mjs +0 -295
  55. package/dist/ui-Cl5P_yQ6.mjs +0 -72
@@ -0,0 +1,92 @@
1
+ import { t as PACKAGE_NAME } from "./version-check-CoWgQs2h.mjs";
2
+ import { n as reportErrors } from "./ui-C8VuPzGp.mjs";
3
+ import { defineCommand } from "citty";
4
+ import { spawn } from "node:child_process";
5
+ //#region src/upgrade.ts
6
+ /**
7
+ * canopy upgrade — install the latest published CLI globally.
8
+ *
9
+ * We shell out to whichever package manager we can detect (npm/pnpm/yarn/bun),
10
+ * defaulting to npm, and let it do the actual install so global prefixes,
11
+ * permissions and lockfiles stay the manager's concern. `--pm` overrides the
12
+ * guess; `--version` pins a specific release.
13
+ */
14
+ /** Best-effort guess of the package manager that installed this CLI. */
15
+ function detectPackageManager() {
16
+ const ua = process.env["npm_config_user_agent"] ?? "";
17
+ if (ua.startsWith("pnpm")) return "pnpm";
18
+ if (ua.startsWith("yarn")) return "yarn";
19
+ if (ua.startsWith("bun")) return "bun";
20
+ const path = process.argv[1] ?? process.execPath;
21
+ if (/[\\/](?:pnpm|\.pnpm)[\\/]/.test(path)) return "pnpm";
22
+ if (/[\\/]\.bun[\\/]/.test(path)) return "bun";
23
+ if (/[\\/](?:Yarn|\.yarn)[\\/]/i.test(path)) return "yarn";
24
+ return "npm";
25
+ }
26
+ /** The global-install argv for `pm`, targeting `spec` (e.g. name@latest). */
27
+ function installArgs(pm, spec) {
28
+ switch (pm) {
29
+ case "pnpm": return [
30
+ "add",
31
+ "-g",
32
+ spec
33
+ ];
34
+ case "yarn": return [
35
+ "global",
36
+ "add",
37
+ spec
38
+ ];
39
+ case "bun": return [
40
+ "add",
41
+ "-g",
42
+ spec
43
+ ];
44
+ default: return [
45
+ "install",
46
+ "-g",
47
+ spec
48
+ ];
49
+ }
50
+ }
51
+ function run(pm, args) {
52
+ return new Promise((resolve, reject) => {
53
+ const child = spawn(pm, args, {
54
+ stdio: "inherit",
55
+ shell: process.platform === "win32"
56
+ });
57
+ child.on("error", (err) => reject(err.code === "ENOENT" ? /* @__PURE__ */ new Error(`\`${pm}\` was not found on your PATH — install it or pass --pm.`) : err));
58
+ child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`${pm} exited with code ${code ?? "unknown"}.`)));
59
+ });
60
+ }
61
+ const upgradeCommand = defineCommand({
62
+ meta: {
63
+ name: "upgrade",
64
+ description: "Update the Canopy CLI to the latest version"
65
+ },
66
+ args: {
67
+ version: {
68
+ type: "string",
69
+ description: "Install this exact version instead of the latest"
70
+ },
71
+ pm: {
72
+ type: "string",
73
+ description: "Package manager to use: npm, pnpm, yarn or bun (auto-detected by default)"
74
+ }
75
+ },
76
+ run: reportErrors("upgrade", async ({ args }) => {
77
+ const chosen = args.pm;
78
+ if (chosen && ![
79
+ "npm",
80
+ "pnpm",
81
+ "yarn",
82
+ "bun"
83
+ ].includes(chosen)) throw new Error(`Unknown --pm "${chosen}" — use npm, pnpm, yarn or bun.`);
84
+ const pm = chosen ?? detectPackageManager();
85
+ const cmd = installArgs(pm, `${PACKAGE_NAME}@${args.version ?? "latest"}`);
86
+ console.log(`Upgrading via ${pm}: ${pm} ${cmd.join(" ")}`);
87
+ await run(pm, cmd);
88
+ console.log(`\nDone. Run \`canopy --version\` to confirm.`);
89
+ })
90
+ });
91
+ //#endregion
92
+ export { upgradeCommand };
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ //#region src/version-check.ts
6
+ /**
7
+ * Best-effort "you're behind" notice for the CLI.
8
+ *
9
+ * On startup we read a small cache at ~/.canopy/version-check.json and, if a
10
+ * newer version is already known, print a one-line hint to stderr. Refreshing
11
+ * that cache from the npm registry happens in a detached background process so
12
+ * it never blocks (or delays) the command the user actually ran. The check is
13
+ * silent on any failure and can be disabled with CANOPY_NO_UPDATE_CHECK.
14
+ */
15
+ const PACKAGE_NAME = "@mediamonks/canopy-cli";
16
+ const CACHE_PATH = join(homedir(), ".canopy", "version-check.json");
17
+ const CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
18
+ const REFRESH_TIMEOUT_MS = 3e3;
19
+ const REFRESH_ENV = "CANOPY_UPDATE_REFRESH";
20
+ /** The npm registry to query, honouring an npm/pnpm registry override. */
21
+ function registryUrl() {
22
+ return `${(process.env["npm_config_registry"] ?? "https://registry.npmjs.org").replace(/\/$/, "")}/${PACKAGE_NAME}/latest`;
23
+ }
24
+ /**
25
+ * Compare two semver strings. Returns -1 if a < b, 1 if a > b, 0 if equal.
26
+ * Handles `x.y.z` with an optional `-prerelease`; a prerelease sorts below its
27
+ * release (1.2.0-rc.1 < 1.2.0). Good enough for a behind/ahead check.
28
+ */
29
+ function compareSemver(a, b) {
30
+ const parse = (v) => {
31
+ const [core = "", pre = ""] = v.replace(/^v/, "").split("-", 2);
32
+ return {
33
+ nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0),
34
+ pre: pre ? pre.split(".") : []
35
+ };
36
+ };
37
+ const pa = parse(a);
38
+ const pb = parse(b);
39
+ for (let i = 0; i < 3; i++) {
40
+ const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
41
+ if (diff !== 0) return diff < 0 ? -1 : 1;
42
+ }
43
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
44
+ if (pa.pre.length === 0) return 1;
45
+ if (pb.pre.length === 0) return -1;
46
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
47
+ const x = pa.pre[i];
48
+ const y = pb.pre[i];
49
+ if (x === y) continue;
50
+ if (x === void 0) return -1;
51
+ if (y === void 0) return 1;
52
+ const nx = Number(x);
53
+ const ny = Number(y);
54
+ return (!Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x < y ? -1 : 1) < 0 ? -1 : 1;
55
+ }
56
+ return 0;
57
+ }
58
+ function readCache() {
59
+ try {
60
+ return JSON.parse(readFileSync(CACHE_PATH, "utf8"));
61
+ } catch {
62
+ return;
63
+ }
64
+ }
65
+ function writeCache(cache) {
66
+ try {
67
+ mkdirSync(join(homedir(), ".canopy"), { recursive: true });
68
+ writeFileSync(CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`);
69
+ } catch {}
70
+ }
71
+ /** Should we stay out of the way entirely (opt-out, CI, or piped output)? */
72
+ function disabled() {
73
+ return process.env["CANOPY_NO_UPDATE_CHECK"] === "1" || process.env["CI"] !== void 0 || !process.stderr.isTTY;
74
+ }
75
+ /**
76
+ * Print an update hint from cache (if we're behind), then kick off a detached
77
+ * background refresh when the cache is stale. Never throws, never blocks.
78
+ */
79
+ function notifyOfUpdate(currentVersion) {
80
+ if (disabled()) return;
81
+ const cache = readCache();
82
+ if (cache?.latest && compareSemver(currentVersion, cache.latest) < 0) process.stderr.write(`\nUpdate available: ${currentVersion} → ${cache.latest}. Run \`canopy upgrade\` to update.\n\n`);
83
+ if (!(cache && Date.now() - Date.parse(cache.lastCheck) < CHECK_INTERVAL_MS)) spawnBackgroundRefresh();
84
+ }
85
+ /** Spawn `canopy` again, flagged to only refresh the cache, fully detached. */
86
+ function spawnBackgroundRefresh() {
87
+ try {
88
+ const entry = process.argv[1];
89
+ if (entry === void 0) return;
90
+ const child = spawn(process.execPath, [entry], {
91
+ detached: true,
92
+ stdio: "ignore",
93
+ env: {
94
+ ...process.env,
95
+ [REFRESH_ENV]: "1"
96
+ }
97
+ });
98
+ child.on("error", () => {});
99
+ child.unref();
100
+ } catch {}
101
+ }
102
+ /**
103
+ * The detached process's whole job: fetch the latest version and update the
104
+ * cache. Always records lastCheck so a flaky registry doesn't cause a fetch on
105
+ * every single invocation. Exits the process when done.
106
+ */
107
+ async function runRefresh() {
108
+ const cache = {
109
+ lastCheck: (/* @__PURE__ */ new Date()).toISOString(),
110
+ latest: readCache()?.latest
111
+ };
112
+ try {
113
+ const controller = new AbortController();
114
+ const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS);
115
+ const res = await fetch(registryUrl(), {
116
+ headers: { accept: "application/json" },
117
+ signal: controller.signal
118
+ });
119
+ clearTimeout(timer);
120
+ if (res.ok) {
121
+ const { version } = await res.json();
122
+ if (version) cache.latest = version;
123
+ }
124
+ } catch {}
125
+ writeCache(cache);
126
+ }
127
+ //#endregion
128
+ export { runRefresh as i, REFRESH_ENV as n, notifyOfUpdate as r, PACKAGE_NAME as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -19,8 +19,8 @@
19
19
  "hono": "^4.8.0",
20
20
  "miniflare": "^4.20260714.0",
21
21
  "vite": "npm:@voidzero-dev/vite-plus-core@0.2.5",
22
- "@mediamonks/canopy-data-plane": "0.0.1",
23
- "@mediamonks/canopy": "0.0.1"
22
+ "@mediamonks/canopy": "0.0.1",
23
+ "@mediamonks/canopy-data-plane": "0.0.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.1.1",
@@ -28,7 +28,7 @@
28
28
  "vite-plus": "0.2.5"
29
29
  },
30
30
  "scripts": {
31
- "build": "vp pack",
31
+ "build": "vp pack && node ./scripts/sync-templates.mjs",
32
32
  "dev": "vp pack --watch",
33
33
  "test": "vp test",
34
34
  "check": "vp check"
@@ -1,36 +0,0 @@
1
- import { a as readConfig } from "./ui-Cl5P_yQ6.mjs";
2
- //#region src/api.ts
3
- /**
4
- * Authenticated requests to the Canopy control plane, using the CLI token
5
- * from `canopy login` (~/.canopy/config.json).
6
- */
7
- var ApiError = class extends Error {
8
- status;
9
- code;
10
- constructor(status, code, detail) {
11
- super(detail ? `${code}: ${detail}` : code);
12
- this.status = status;
13
- this.code = code;
14
- }
15
- };
16
- async function apiRequest(method, path, body, rawContentType) {
17
- const config = readConfig();
18
- if (!config.token) throw new Error("Not logged in — run `canopy login` first.");
19
- const isForm = body instanceof FormData;
20
- const response = await fetch(`${config.apiUrl}/v1${path}`, {
21
- method,
22
- headers: {
23
- authorization: `Bearer ${config.token}`,
24
- ...body === void 0 || isForm ? {} : { "content-type": rawContentType ?? "application/json" }
25
- },
26
- body: body === void 0 ? void 0 : isForm ? body : rawContentType !== void 0 ? body : JSON.stringify(body)
27
- });
28
- if (!response.ok) {
29
- const payload = await response.json().catch(() => ({}));
30
- if (response.status === 401) throw new Error("Session expired — run `canopy login` again.");
31
- throw new ApiError(response.status, payload.error ?? `http_${response.status}`, payload.detail);
32
- }
33
- return await response.json();
34
- }
35
- //#endregion
36
- export { apiRequest as t };
@@ -1,2 +0,0 @@
1
- import { t as devCommand } from "./dev-BB7CwqoI.mjs";
2
- export { devCommand };
@@ -1,295 +0,0 @@
1
- import { i as unwrap, n as reportErrors, r as requireTty } from "./ui-Cl5P_yQ6.mjs";
2
- import { t as apiRequest } from "./api-BEY4O4ap.mjs";
3
- import { r as projectUrl } from "./project-CrrpLEPs.mjs";
4
- import { defineCommand } from "citty";
5
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
- import * as p from "@clack/prompts";
7
- import { join, resolve } from "node:path";
8
- //#region src/template.ts
9
- function templateFiles({ name, slug }) {
10
- return {
11
- "canopy.json": `${JSON.stringify({
12
- slug,
13
- dist: "."
14
- }, null, 2)}\n`,
15
- ".gitignore": `.canopy/
16
- .DS_Store
17
- `,
18
- "index.html": `<!doctype html>
19
- <html lang="en">
20
- <head>
21
- <meta charset="UTF-8" />
22
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
23
- <title>${name}</title>
24
- <link rel="stylesheet" href="./style.css" />
25
- </head>
26
- <body>
27
- <main>
28
- <h1>${name}</h1>
29
- <p class="status" id="status">connecting to Canopy…</p>
30
- <section id="app">
31
- <p class="placeholder">Start building — edit <code>main.js</code>, then run <code>canopy deploy</code>.</p>
32
- </section>
33
- </main>
34
- <script type="module" src="./main.js"><\/script>
35
- </body>
36
- </html>
37
- `,
38
- "main.js": `// The Canopy SDK is served by the platform itself — nothing to install.
39
- // Auth (auth), data (db), files (blob) and AI (ai) are all live — see
40
- // CLAUDE.md for the full surface.
41
- import { createCanopy } from "/_api/v1/sdk.js";
42
-
43
- const canopy = createCanopy();
44
-
45
- const user = await canopy.auth.user();
46
- document.getElementById("status").textContent = \`signed in as \${user.email}\`;
47
- `,
48
- "style.css": `:root {
49
- font-family: system-ui, sans-serif;
50
- color-scheme: light dark;
51
- }
52
-
53
- body {
54
- margin: 0;
55
- display: grid;
56
- place-items: center;
57
- min-height: 100vh;
58
- }
59
-
60
- main {
61
- max-width: 40rem;
62
- padding: 2rem;
63
- text-align: center;
64
- }
65
-
66
- .status {
67
- font-size: 0.9rem;
68
- opacity: 0.7;
69
- }
70
-
71
- .placeholder {
72
- border: 1px dashed color-mix(in srgb, currentColor 30%, transparent);
73
- border-radius: 8px;
74
- padding: 1.5rem;
75
- opacity: 0.6;
76
- }
77
- `,
78
- "CLAUDE.md": `# ${name} — a Canopy project
79
-
80
- This is a static web app on Canopy, the managed platform for internal Monks
81
- projects. Everything is served behind company login; there is no
82
- infrastructure to manage and none should be added.
83
-
84
- ## The paved road
85
-
86
- - Frontend only: html/css/js in this directory (add a bundler if you must,
87
- set \`build\` and \`dist\` in canopy.json accordingly).
88
- - All backend needs go through the Canopy SDK — never call external APIs with
89
- secrets from the browser, never add your own server:
90
- \`\`\`js
91
- import { createCanopy } from "/_api/v1/sdk.js";
92
- const canopy = createCanopy();
93
- const user = await canopy.auth.user(); // { id, email, name, kind }
94
-
95
- // Data — document collections on the project's own database (no tables, no migrations):
96
- const todos = canopy.db.collection("todos");
97
- const doc = await todos.insert({ title: "hi", done: false }); // -> data + { _id, _createdAt, _updatedAt }
98
- await todos.find({ where: { done: false }, orderBy: "_createdAt", order: "desc", limit: 20 });
99
- await todos.get(doc._id); // Doc | null
100
- await todos.update(doc._id, { done: true }); // merge-patch; null removes a key
101
- await todos.delete(doc._id);
102
- await todos.search("groceries"); // full-text search over the collection
103
- await canopy.db.sql("SELECT ... WHERE x = ?", [param]); // raw SQL escape hatch, same database — always parameterize with ?
104
-
105
- // Files — private storage on the project's own prefix (never public):
106
- await canopy.blob.put("notes.txt", "hello", { contentType: "text/plain" });
107
- const blob = await canopy.blob.get("notes.txt"); // Blob | null — .text(), .arrayBuffer(), URL.createObjectURL(...)
108
- const { url } = await canopy.blob.getUrl("notes.txt"); // short-lived URL for <img src> / links
109
- await canopy.blob.list("some/prefix/"); // [{ key, size, contentType, uploadedAt }]
110
- await canopy.blob.delete("notes.txt");
111
-
112
- // AI — LLM calls through the project's metered, budget-capped gateway:
113
- const { message } = await canopy.ai.chat({ messages: [{ role: "user", content: "hi" }] });
114
- // Streaming variant: canopy.ai.chatStream(sameOptions) -> SSE ReadableStream.
115
- const { baseUrl, models } = await canopy.ai.endpoint(); // OpenAI-compatible base URL for ecosystem clients (same budget)
116
- \`\`\`
117
- - AI spend is hard-capped per project; over budget, calls fail with
118
- \`ai_budget_exceeded\` (429) — handle it, don't retry in a loop.
119
- - Develop with \`canopy dev\` — a local server on this directory whose
120
- \`/_api\` proxies to the real platform. That is LIVE project data, the same
121
- the deployed app uses: read freely, but take a \`canopy db snapshot\` before
122
- risky data changes.
123
- - Prefer \`canopy dev --local\` for risky or destructive experiments — a
124
- local-data sandbox (db/blob in \`.canopy/local/\`, writes never reach the
125
- platform). Add \`--pull\` to hydrate it from a fresh copy of the live
126
- database (pull-only; blobs stay empty). \`canopy.ai\` still works there —
127
- it proxies to the platform and is metered as usual.
128
- - Deploy with \`canopy deploy\` — the live URL is
129
- https://${slug}.thanarie.dev (company login required).
130
-
131
- ## Boundaries
132
-
133
- - Do not add auth — the platform handles it before any request reaches this app.
134
- - Do not store secrets anywhere in this project.
135
- - Keep it a static site: no SSR, no custom servers, no databases outside canopy.db.
136
- `,
137
- "README.md": `# ${name}
138
-
139
- A [Canopy](https://dashboard.thanarie.dev) project. Live (behind company
140
- login) at https://${slug}.thanarie.dev after the first \`canopy deploy\`.
141
-
142
- - \`canopy dev\` — develop locally against the live platform (real data!)
143
- - \`canopy dev --local [--pull]\` — offline sandbox with local data instead
144
- - \`canopy deploy\` — publish the current directory
145
- - \`CLAUDE.md\` — guardrails and SDK guide for coding agents
146
- `
147
- };
148
- }
149
- //#endregion
150
- //#region src/projects.ts
151
- /**
152
- * canopy create | projects | delete — the project registry from the terminal
153
- * (docs/04-roadmap.md Phase 1 item 2). `create` registers the project, then
154
- * scaffolds ./<slug>/ from the starter template (--no-scaffold to skip, e.g.
155
- * for an existing directory).
156
- */
157
- function slugify(input) {
158
- return input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-+|-+$)/g, "").slice(0, 40);
159
- }
160
- const createCommand = defineCommand({
161
- meta: {
162
- name: "create",
163
- description: "Create a new Canopy project"
164
- },
165
- args: {
166
- name: {
167
- type: "positional",
168
- description: "Project name (prompted for when omitted)",
169
- required: false
170
- },
171
- slug: {
172
- type: "string",
173
- description: "URL slug (defaults to a slugified name)"
174
- },
175
- scaffold: {
176
- type: "boolean",
177
- default: true,
178
- description: "Scaffold ./<slug>/ from the starter template",
179
- negativeDescription: "Only register — no files written"
180
- }
181
- },
182
- run: reportErrors("create", async ({ args }) => {
183
- let name = args._.join(" ").trim();
184
- p.intro("canopy create");
185
- if (!name) {
186
- requireTty("pass the project name as an argument.");
187
- name = unwrap(await p.text({
188
- message: "Project name",
189
- validate: (value) => value?.trim() ? void 0 : "A project name is required."
190
- })).trim();
191
- }
192
- let slug = args.slug;
193
- if (!slug && process.stdin.isTTY) slug = unwrap(await p.text({
194
- message: "Slug",
195
- defaultValue: slugify(name),
196
- placeholder: slugify(name),
197
- validate: (value) => !value || /^[a-z0-9][a-z0-9-]*$/.test(value) ? void 0 : "Lowercase letters, digits and dashes only."
198
- })).trim();
199
- slug ||= slugify(name);
200
- const targetDir = resolve(slug);
201
- if (args.scaffold && existsSync(targetDir)) throw new Error(`Directory ./${slug} already exists — remove it, or use --no-scaffold to only register.`);
202
- const spin = p.spinner();
203
- spin.start(`Registering ${slug}…`);
204
- let project;
205
- try {
206
- ({project} = await apiRequest("POST", "/projects", {
207
- slug,
208
- name
209
- }));
210
- } catch (err) {
211
- spin.error("Create failed.");
212
- throw err;
213
- }
214
- spin.stop(`Created ${project.name} — ${projectUrl(project.slug)}`);
215
- if (args.scaffold) {
216
- mkdirSync(targetDir, { recursive: true });
217
- for (const [path, content] of Object.entries(templateFiles({
218
- name,
219
- slug
220
- }))) writeFileSync(join(targetDir, path), content);
221
- p.note(`cd ${slug}\ncanopy dev (or deploy right away: canopy deploy)`, `Scaffolded ./${slug}/ — next`);
222
- p.outro(`Live URL (after first deploy): ${projectUrl(project.slug)}`);
223
- } else p.outro("Registered without scaffolding — add a canopy.json to deploy an existing app.");
224
- })
225
- });
226
- const projectsCommand = defineCommand({
227
- meta: {
228
- name: "projects",
229
- description: "List your projects"
230
- },
231
- args: { all: {
232
- type: "boolean",
233
- default: false,
234
- description: "Include projects you can't edit"
235
- } },
236
- run: reportErrors("projects", async ({ args }) => {
237
- const { projects, is_admin: isAdmin } = await apiRequest("GET", "/projects");
238
- const mine = args.all || isAdmin ? projects : projects.filter((project) => project.role);
239
- if (mine.length === 0) {
240
- console.log(projects.length === 0 ? "No projects yet — run `canopy create`." : `No projects of your own yet — run \`canopy create\`, or --all to list all ${projects.length}.`);
241
- return;
242
- }
243
- const width = Math.max(...mine.map((project) => project.slug.length));
244
- for (const project of mine) {
245
- const role = project.role ?? (isAdmin ? "admin" : `owner: ${project.owner_email}`);
246
- console.log(`${project.slug.padEnd(width)} ${project.status.padEnd(7)} ${role.padEnd(6)} ${projectUrl(project.slug)} ${project.name}`);
247
- }
248
- })
249
- });
250
- const deleteCommand = defineCommand({
251
- meta: {
252
- name: "delete",
253
- description: "Delete a project (full teardown)"
254
- },
255
- args: {
256
- slug: {
257
- type: "positional",
258
- description: "Project slug (picked from a list when omitted)",
259
- required: false
260
- },
261
- yes: {
262
- type: "boolean",
263
- default: false,
264
- description: "Skip the type-the-slug confirmation"
265
- }
266
- },
267
- run: reportErrors("delete", async ({ args }) => {
268
- let slug = args.slug;
269
- if (!slug) {
270
- requireTty("pass the project slug as an argument.");
271
- const listing = await apiRequest("GET", "/projects");
272
- const projects = listing.is_admin ? listing.projects : listing.projects.filter((project) => project.role === "owner");
273
- if (projects.length === 0) throw new Error("No projects to delete.");
274
- slug = unwrap(await p.select({
275
- message: "Project to delete",
276
- options: projects.map((project) => ({
277
- value: project.slug,
278
- label: project.slug,
279
- hint: project.name
280
- }))
281
- }));
282
- }
283
- if (!args.yes) {
284
- requireTty("pass --yes to delete without confirmation.");
285
- if (unwrap(await p.text({ message: `Delete "${slug}" permanently? This tears down its database and files. Type the slug to confirm.` })).trim() !== slug) {
286
- p.cancel("Aborted.");
287
- return;
288
- }
289
- }
290
- await apiRequest("DELETE", `/projects/${slug}`);
291
- p.log.success(`Deleted ${slug}.`);
292
- })
293
- });
294
- //#endregion
295
- export { createCommand, deleteCommand, projectsCommand };
@@ -1,72 +0,0 @@
1
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { cancel, isCancel } from "@clack/prompts";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { spawn } from "node:child_process";
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
- //#region src/ui.ts
36
- /**
37
- * Shared terminal UX plumbing: clack prompt unwrapping and uniform
38
- * command error reporting.
39
- */
40
- /** Unwrap a clack prompt result, exiting cleanly when the user cancels (ctrl-c). */
41
- function unwrap(result) {
42
- if (isCancel(result)) {
43
- cancel("Aborted.");
44
- process.exit(130);
45
- }
46
- return result;
47
- }
48
- /** Wrap a command action so failures print as `canopy <name>: <message>` and exit 1. */
49
- function reportErrors(name, action) {
50
- return async (...args) => {
51
- try {
52
- await action(...args);
53
- } catch (err) {
54
- console.error(`canopy ${name}: ${err instanceof Error ? err.message : String(err)}`);
55
- process.exit(1);
56
- }
57
- };
58
- }
59
- /** Guard interactive prompts: bail with a actionable message on non-TTY runs. */
60
- function requireTty(hint) {
61
- if (!process.stdin.isTTY) throw new Error(`This needs an interactive terminal — ${hint}`);
62
- }
63
- /** Open a URL in the default browser, fire-and-forget. */
64
- function openBrowser(url) {
65
- spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open", [url], {
66
- stdio: "ignore",
67
- detached: true,
68
- shell: process.platform === "win32"
69
- }).on("error", () => {}).unref();
70
- }
71
- //#endregion
72
- export { readConfig as a, unwrap as i, reportErrors as n, writeConfig as o, requireTty as r, openBrowser as t };