@mediamonks/canopy-cli 0.0.1 → 0.2.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 (66) 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/docs/ci.md +48 -0
  8. package/dist/docs/cli.md +67 -0
  9. package/dist/docs/data.md +49 -0
  10. package/dist/docs/deploy.md +50 -0
  11. package/dist/docs/dev.md +44 -0
  12. package/dist/docs/getting-started.md +100 -0
  13. package/dist/docs/troubleshooting.md +61 -0
  14. package/dist/docs-C3aOAx_A.mjs +179 -0
  15. package/dist/index.mjs +26 -16
  16. package/dist/{local-DhufX0eJ.mjs → local-951zc9qJ.mjs} +2 -2
  17. package/dist/{login-DrXKOEmr.mjs → login-Dif9Y32q.mjs} +38 -10
  18. package/dist/{open-DFWvrLFc.mjs → open-xLiGUz_W.mjs} +2 -2
  19. package/dist/package-OcWG7JAW.mjs +4 -0
  20. package/dist/{project-CrrpLEPs.mjs → project-CpiPLNby.mjs} +1 -1
  21. package/dist/projects-DJT_mJDR.mjs +228 -0
  22. package/dist/skill/canopy/SKILL.md +80 -0
  23. package/dist/skill-jbmERP1X.mjs +84 -0
  24. package/dist/templates/spa/.github/workflows/canopy-deploy.yml +34 -0
  25. package/dist/templates/spa/AGENTS.md +43 -0
  26. package/dist/templates/spa/README.md +14 -0
  27. package/dist/templates/spa/_gitignore +4 -0
  28. package/dist/templates/spa/canopy.json +5 -0
  29. package/dist/templates/spa/index.html +12 -0
  30. package/dist/templates/spa/package.json +21 -0
  31. package/dist/templates/spa/src/app.tsx +25 -0
  32. package/dist/templates/spa/src/canopy.ts +5 -0
  33. package/dist/templates/spa/src/main.tsx +10 -0
  34. package/dist/templates/spa/src/style.css +29 -0
  35. package/dist/templates/spa/tsconfig.json +16 -0
  36. package/dist/templates/spa/vite.config.ts +7 -0
  37. package/dist/templates/static/AGENTS.md +41 -0
  38. package/dist/templates/static/README.md +9 -0
  39. package/dist/templates/static/_gitignore +2 -0
  40. package/dist/templates/static/canopy.json +4 -0
  41. package/dist/templates/static/index.html +21 -0
  42. package/dist/templates/static/main.js +9 -0
  43. package/dist/templates/static/style.css +29 -0
  44. package/dist/templates/worker/.github/workflows/canopy-deploy.yml +34 -0
  45. package/dist/templates/worker/AGENTS.md +51 -0
  46. package/dist/templates/worker/README.md +18 -0
  47. package/dist/templates/worker/_gitignore +4 -0
  48. package/dist/templates/worker/canopy.json +6 -0
  49. package/dist/templates/worker/index.html +12 -0
  50. package/dist/templates/worker/package.json +22 -0
  51. package/dist/templates/worker/src/app.tsx +25 -0
  52. package/dist/templates/worker/src/canopy.ts +5 -0
  53. package/dist/templates/worker/src/main.tsx +10 -0
  54. package/dist/templates/worker/src/style.css +29 -0
  55. package/dist/templates/worker/tsconfig.json +16 -0
  56. package/dist/templates/worker/vite.config.ts +7 -0
  57. package/dist/templates/worker/worker/index.ts +29 -0
  58. package/dist/token-rTD9jxPZ.mjs +75 -0
  59. package/dist/ui-C8VuPzGp.mjs +40 -0
  60. package/dist/upgrade-Y8ejvoeg.mjs +99 -0
  61. package/dist/version-check-CoWgQs2h.mjs +128 -0
  62. package/package.json +2 -2
  63. package/dist/api-BEY4O4ap.mjs +0 -36
  64. package/dist/dev-Bvy21srH.mjs +0 -2
  65. package/dist/projects-f_KocDae.mjs +0 -295
  66. package/dist/ui-Cl5P_yQ6.mjs +0 -72
@@ -0,0 +1,179 @@
1
+ import { t as readConfig } from "./config-C-z7st1X.mjs";
2
+ import { n as reportErrors } from "./ui-C8VuPzGp.mjs";
3
+ import { defineCommand } from "citty";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ //#region src/docs.ts
9
+ /**
10
+ * canopy docs — user/agent-facing guides, served live by the platform
11
+ * (GET /v1/docs) so they never go stale, with a local cache and a bundled
12
+ * snapshot as offline fallbacks.
13
+ *
14
+ * The platform requires a login for docs (internal platform), so fetches carry
15
+ * the CLI token when there is one; without it we simply fall back to the
16
+ * bundled snapshot, so `canopy docs` still works before sign-in.
17
+ *
18
+ * Resolution order for content:
19
+ * 1. platform (unless a fresh cache exists and --refresh wasn't passed)
20
+ * 2. local cache (~/.canopy/docs-cache/)
21
+ * 3. bundled snapshot shipped with the CLI (dist/docs, repo docs/guides in dev)
22
+ */
23
+ const cacheDir = join(homedir(), ".canopy", "docs-cache");
24
+ const indexPath = join(cacheDir, "index.json");
25
+ const CACHE_TTL_MS = 3600 * 1e3;
26
+ const FETCH_TIMEOUT_MS = 4e3;
27
+ /** The bundled snapshot dir: dist/docs when published, repo docs/guides in dev. */
28
+ function bundledDocsDir() {
29
+ const base = fileURLToPath(new URL(".", import.meta.url));
30
+ for (const candidate of [join(base, "docs"), join(base, "..", "..", "..", "docs", "guides")]) if (existsSync(candidate)) return candidate;
31
+ }
32
+ /** Parse `---\ntitle: …\nsummary: …\n---\n<body>` frontmatter. */
33
+ function parseFrontmatter(raw) {
34
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw);
35
+ if (!match?.[1] || match[2] === void 0) return { body: raw.trim() };
36
+ const meta = {};
37
+ for (const line of match[1].split("\n")) {
38
+ const kv = /^([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line);
39
+ if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
40
+ }
41
+ return {
42
+ title: meta["title"],
43
+ summary: meta["summary"],
44
+ body: match[2].trim()
45
+ };
46
+ }
47
+ /** Bearer header from the deploy token (CI) or the CLI login, if either exists. */
48
+ function authHeaders() {
49
+ const token = process.env["CANOPY_DEPLOY_TOKEN"] || readConfig().token;
50
+ return token ? { authorization: `Bearer ${token}` } : {};
51
+ }
52
+ async function fetchRes(url) {
53
+ try {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
56
+ const res = await fetch(url, {
57
+ signal: controller.signal,
58
+ headers: {
59
+ accept: "*/*",
60
+ ...authHeaders()
61
+ }
62
+ });
63
+ clearTimeout(timer);
64
+ return res;
65
+ } catch {
66
+ return;
67
+ }
68
+ }
69
+ async function fetchJson(url) {
70
+ const res = await fetchRes(url);
71
+ return res?.ok ? await res.json() : void 0;
72
+ }
73
+ async function fetchText(url) {
74
+ const res = await fetchRes(url);
75
+ return res?.ok ? await res.text() : void 0;
76
+ }
77
+ function cacheFresh() {
78
+ try {
79
+ const { fetchedAt } = JSON.parse(readFileSync(indexPath, "utf8"));
80
+ return fetchedAt !== void 0 && Date.now() - Date.parse(fetchedAt) < CACHE_TTL_MS;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+ function writeCache(file, content) {
86
+ try {
87
+ mkdirSync(cacheDir, { recursive: true });
88
+ writeFileSync(join(cacheDir, file), content);
89
+ } catch {}
90
+ }
91
+ /** Topics from cache, else from the bundled snapshot's frontmatter. */
92
+ function topicsFromFallback() {
93
+ try {
94
+ const cached = JSON.parse(readFileSync(indexPath, "utf8"));
95
+ if (cached.topics?.length) return cached.topics;
96
+ } catch {}
97
+ const dir = bundledDocsDir();
98
+ if (!dir) return [];
99
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
100
+ const slug = f.replace(/\.md$/, "");
101
+ const { title, summary } = parseFrontmatter(readFileSync(join(dir, f), "utf8"));
102
+ return {
103
+ slug,
104
+ title: title ?? slug,
105
+ summary: summary ?? ""
106
+ };
107
+ });
108
+ }
109
+ /** A single doc's body from cache, else from the bundled snapshot. */
110
+ function bodyFromFallback(slug) {
111
+ const cached = join(cacheDir, `${slug}.md`);
112
+ if (existsSync(cached)) return readFileSync(cached, "utf8");
113
+ const dir = bundledDocsDir();
114
+ const file = dir && join(dir, `${slug}.md`);
115
+ if (file && existsSync(file)) return parseFrontmatter(readFileSync(file, "utf8")).body;
116
+ }
117
+ async function listTopics(refresh) {
118
+ const { apiUrl } = readConfig();
119
+ if (refresh || !cacheFresh()) {
120
+ const data = await fetchJson(`${apiUrl}/v1/docs`);
121
+ if (data?.topics) {
122
+ writeCache("index.json", JSON.stringify({
123
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
124
+ ...data
125
+ }));
126
+ return data.topics;
127
+ }
128
+ }
129
+ return topicsFromFallback();
130
+ }
131
+ async function readTopic(slug, refresh) {
132
+ const { apiUrl } = readConfig();
133
+ if (refresh || !existsSync(join(cacheDir, `${slug}.md`))) {
134
+ const body = await fetchText(`${apiUrl}/v1/docs/${encodeURIComponent(slug)}`);
135
+ if (body !== void 0) {
136
+ writeCache(`${slug}.md`, body);
137
+ return body;
138
+ }
139
+ }
140
+ return bodyFromFallback(slug);
141
+ }
142
+ const docsCommand = defineCommand({
143
+ meta: {
144
+ name: "docs",
145
+ description: "Read the Canopy guides (offline-capable)"
146
+ },
147
+ args: {
148
+ topic: {
149
+ type: "positional",
150
+ required: false,
151
+ description: "Topic to print (omit to list)"
152
+ },
153
+ refresh: {
154
+ type: "boolean",
155
+ default: false,
156
+ description: "Re-fetch from the platform"
157
+ }
158
+ },
159
+ run: reportErrors("docs", async ({ args }) => {
160
+ const refresh = args.refresh;
161
+ const topic = args.topic;
162
+ if (!topic) {
163
+ const topics = await listTopics(refresh);
164
+ if (topics.length === 0) throw new Error("No docs available — check your connection, or reinstall the CLI.");
165
+ const width = Math.max(...topics.map((t) => t.slug.length));
166
+ console.log("Canopy docs — run `canopy docs <topic>`:\n");
167
+ for (const t of topics) console.log(` ${t.slug.padEnd(width)} ${t.summary}`);
168
+ return;
169
+ }
170
+ const body = await readTopic(topic, refresh);
171
+ if (body === void 0) {
172
+ const known = (await listTopics(false)).map((t) => t.slug).join(", ");
173
+ throw new Error(`No doc "${topic}".${known ? ` Available: ${known}.` : ""}`);
174
+ }
175
+ console.log(body);
176
+ })
177
+ });
178
+ //#endregion
179
+ export { docsCommand };
package/dist/index.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { t as version } from "./package-OcWG7JAW.mjs";
3
+ import { i as runRefresh, r as notifyOfUpdate } from "./version-check-CoWgQs2h.mjs";
2
4
  import { defineCommand, runMain } from "citty";
3
- //#region package.json
4
- var version = "0.0.1";
5
- //#endregion
6
5
  //#region src/index.ts
7
6
  /**
8
7
  * canopy — the Canopy CLI.
@@ -13,10 +12,15 @@ var version = "0.0.1";
13
12
  * create scaffold + register a project [phase 1]
14
13
  * dev [--local [--pull]] local dev: remote-first, or offline sandbox [phase 1/2]
15
14
  * deploy build & deploy assets (+ worker) [phase 1/2]
15
+ * token create|list|revoke deploy tokens for CI [phase 2]
16
16
  * db pull|snapshot|restore [phase 2]
17
17
  * logs tail the project's worker logs [phase 2]
18
18
  * open open the deployed project / dashboard [phase 1]
19
19
  */
20
+ if (process.env["CANOPY_UPDATE_REFRESH"] === "1") {
21
+ await runRefresh();
22
+ process.exit(0);
23
+ }
20
24
  /** A roadmap command that has not landed yet (docs/04-roadmap.md). */
21
25
  function planned(name, description, phase) {
22
26
  return defineCommand({
@@ -30,26 +34,32 @@ function planned(name, description, phase) {
30
34
  }
31
35
  });
32
36
  }
33
- await runMain(defineCommand({
37
+ const main = defineCommand({
34
38
  meta: {
35
39
  name: "canopy",
36
40
  version,
37
41
  description: "canopy — create and deploy vibecoded projects"
38
42
  },
39
43
  subCommands: {
40
- login: () => import("./login-DrXKOEmr.mjs").then((m) => m.loginCommand),
41
- whoami: () => import("./login-DrXKOEmr.mjs").then((m) => m.whoamiCommand),
42
- logout: () => import("./login-DrXKOEmr.mjs").then((m) => m.logoutCommand),
43
- create: () => import("./projects-f_KocDae.mjs").then((m) => m.createCommand),
44
- projects: () => import("./projects-f_KocDae.mjs").then((m) => m.projectsCommand),
45
- delete: () => import("./projects-f_KocDae.mjs").then((m) => m.deleteCommand),
46
- dev: () => import("./dev-Bvy21srH.mjs").then((m) => m.devCommand),
47
- deploy: () => import("./deploy-BfhAlb91.mjs").then((m) => m.deployCommand),
48
- rollback: () => import("./deploy-BfhAlb91.mjs").then((m) => m.rollbackCommand),
49
- db: () => import("./db-BBcgT1md.mjs").then((m) => m.dbCommand),
50
- open: () => import("./open-DFWvrLFc.mjs").then((m) => m.openCommand),
44
+ login: () => import("./login-Dif9Y32q.mjs").then((m) => m.loginCommand),
45
+ whoami: () => import("./login-Dif9Y32q.mjs").then((m) => m.whoamiCommand),
46
+ logout: () => import("./login-Dif9Y32q.mjs").then((m) => m.logoutCommand),
47
+ create: () => import("./projects-DJT_mJDR.mjs").then((m) => m.createCommand),
48
+ projects: () => import("./projects-DJT_mJDR.mjs").then((m) => m.projectsCommand),
49
+ delete: () => import("./projects-DJT_mJDR.mjs").then((m) => m.deleteCommand),
50
+ dev: () => import("./dev-D_GlUULk.mjs").then((m) => m.devCommand),
51
+ deploy: () => import("./deploy-N88l4Kt3.mjs").then((m) => m.deployCommand),
52
+ rollback: () => import("./deploy-N88l4Kt3.mjs").then((m) => m.rollbackCommand),
53
+ token: () => import("./token-rTD9jxPZ.mjs").then((m) => m.tokenCommand),
54
+ db: () => import("./db-CtUG4vfG.mjs").then((m) => m.dbCommand),
55
+ open: () => import("./open-xLiGUz_W.mjs").then((m) => m.openCommand),
56
+ docs: () => import("./docs-C3aOAx_A.mjs").then((m) => m.docsCommand),
57
+ skill: () => import("./skill-jbmERP1X.mjs").then((m) => m.skillCommand),
58
+ upgrade: () => import("./upgrade-Y8ejvoeg.mjs").then((m) => m.upgradeCommand),
51
59
  logs: planned("logs", "Tail worker logs", "2")
52
60
  }
53
- }));
61
+ });
62
+ if (process.argv[2] !== "upgrade") notifyOfUpdate(version);
63
+ await runMain(main);
54
64
  //#endregion
55
65
  export {};
@@ -1,5 +1,5 @@
1
- import { t as apiRequest } from "./api-BEY4O4ap.mjs";
2
- import { n as rewriteApiPath } from "./dev-BB7CwqoI.mjs";
1
+ import { t as apiRequest } from "./api-CyJ-wBZh.mjs";
2
+ import { n as rewriteApiPath } from "./dev-DTGW0lm0.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
@@ -1,4 +1,5 @@
1
- import { a as readConfig, n as reportErrors, o as writeConfig, t as openBrowser } from "./ui-Cl5P_yQ6.mjs";
1
+ import { n as writeConfig, t as readConfig } from "./config-C-z7st1X.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-C8VuPzGp.mjs";
2
3
  import { defineCommand } from "citty";
3
4
  import * as p from "@clack/prompts";
4
5
  import { createHash, randomBytes } from "node:crypto";
@@ -78,10 +79,26 @@ const loginCommand = defineCommand({
78
79
  name: "login",
79
80
  description: "Sign in to Canopy via OneLogin"
80
81
  },
81
- run: reportErrors("login", () => login())
82
+ args: { json: {
83
+ type: "boolean",
84
+ default: false,
85
+ description: "Emit machine-readable status to stdout (for agents/CI)"
86
+ } },
87
+ run: reportErrors("login", async ({ args }) => {
88
+ const json = args.json;
89
+ try {
90
+ await login(json);
91
+ } catch (err) {
92
+ if (json) console.log(JSON.stringify({
93
+ status: "error",
94
+ message: err instanceof Error ? err.message : String(err)
95
+ }));
96
+ throw err;
97
+ }
98
+ })
82
99
  });
83
- async function login() {
84
- p.intro("canopy login");
100
+ async function login(json) {
101
+ if (!json) p.intro("canopy login");
85
102
  const config = readConfig();
86
103
  const { issuer, cliClientId, apiUrl } = config;
87
104
  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).");
@@ -101,18 +118,24 @@ async function login() {
101
118
  code_challenge_method: "S256"
102
119
  }).toString();
103
120
  const authorizeHref = authorizeUrl.toString();
104
- p.note(`If nothing opens, visit:\n${authorizeHref}`, "Opening OneLogin in your browser");
105
121
  openBrowser(authorizeHref);
106
- const spin = p.spinner();
107
- spin.start("Waiting for you to sign in in the browser…");
122
+ const spin = json ? void 0 : p.spinner();
123
+ if (json) console.log(JSON.stringify({
124
+ status: "waiting",
125
+ url: authorizeHref
126
+ }));
127
+ else {
128
+ p.note(`If nothing opens, visit:\n${authorizeHref}`, "Opening OneLogin in your browser");
129
+ spin?.start("Waiting for you to sign in in the browser…");
130
+ }
108
131
  let code;
109
132
  try {
110
133
  code = await waitForCallback(state);
111
134
  } catch (err) {
112
- spin.error("Sign-in failed.");
135
+ spin?.error("Sign-in failed.");
113
136
  throw err;
114
137
  }
115
- spin.stop("Signed in with OneLogin.");
138
+ spin?.stop("Signed in with OneLogin.");
116
139
  const tokenRes = await fetch(discovery.token_endpoint, {
117
140
  method: "POST",
118
141
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -140,7 +163,12 @@ async function login() {
140
163
  tokenExpiresAt: expires_at,
141
164
  email: user.email
142
165
  });
143
- p.outro(`Logged in as ${user.name} <${user.email}> (token expires ${expires_at}).`);
166
+ if (json) console.log(JSON.stringify({
167
+ status: "ok",
168
+ email: user.email,
169
+ expiresAt: expires_at
170
+ }));
171
+ else p.outro(`Logged in as ${user.name} <${user.email}> (token expires ${expires_at}).`);
144
172
  }
145
173
  //#endregion
146
174
  export { loginCommand, logoutCommand, whoamiCommand };
@@ -1,5 +1,5 @@
1
- import { n as reportErrors, t as openBrowser } from "./ui-Cl5P_yQ6.mjs";
2
- import { i as resolveProject, r as projectUrl, t as projectDirArgs } from "./project-CrrpLEPs.mjs";
1
+ import { i as resolveProject, r as projectUrl, t as projectDirArgs } from "./project-CpiPLNby.mjs";
2
+ import { n as reportErrors, t as openBrowser } from "./ui-C8VuPzGp.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/open.ts
5
5
  /**
@@ -0,0 +1,4 @@
1
+ //#region package.json
2
+ var version = "0.2.0";
3
+ //#endregion
4
+ export { version as t };
@@ -1,4 +1,4 @@
1
- import { a as readConfig } from "./ui-Cl5P_yQ6.mjs";
1
+ import { t as readConfig } from "./config-C-z7st1X.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  //#region src/project.ts
@@ -0,0 +1,228 @@
1
+ import { t as apiRequest } from "./api-CyJ-wBZh.mjs";
2
+ import { r as projectUrl } from "./project-CpiPLNby.mjs";
3
+ import { i as unwrap, n as reportErrors, r as requireTty } from "./ui-C8VuPzGp.mjs";
4
+ import { defineCommand } from "citty";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { join, relative, resolve, sep } from "node:path";
7
+ import * as p from "@clack/prompts";
8
+ import { fileURLToPath } from "node:url";
9
+ //#region src/template.ts
10
+ /**
11
+ * Template registry + scaffolder for `canopy create`. Templates are real,
12
+ * runnable project directories in packages/templates — linted and built like
13
+ * any code — copied into the CLI's dist/templates at build time
14
+ * (scripts/sync-templates.mjs) so the published tarball is self-contained.
15
+ *
16
+ * Scaffolding copies a template recursively, substitutes {{name}}/{{slug}},
17
+ * and renames _gitignore → .gitignore (npm strips .gitignore files from
18
+ * published tarballs, so templates can't ship one under its real name).
19
+ */
20
+ const TEMPLATES = [
21
+ {
22
+ id: "static",
23
+ label: "Static site",
24
+ hint: "plain html/css/js, no build step (default)"
25
+ },
26
+ {
27
+ id: "spa",
28
+ label: "React SPA",
29
+ hint: "Vite + React + TypeScript, SDK from npm",
30
+ needsInstall: true
31
+ },
32
+ {
33
+ id: "worker",
34
+ label: "React SPA + backend Worker",
35
+ hint: "adds Hono handlers in worker/ — Phase 2 preview",
36
+ needsInstall: true,
37
+ hidden: true
38
+ }
39
+ ];
40
+ function templatesRoot() {
41
+ const base = fileURLToPath(new URL(".", import.meta.url));
42
+ for (const candidate of [join(base, "templates"), join(base, "..", "..", "templates")]) if (existsSync(join(candidate, "static"))) return candidate;
43
+ throw new Error("Template files not found — the CLI build is incomplete (missing templates/).");
44
+ }
45
+ function scaffoldTemplate(templateId, targetDir, vars) {
46
+ const source = join(templatesRoot(), templateId);
47
+ mkdirSync(targetDir, { recursive: true });
48
+ for (const entry of readdirSync(source, {
49
+ recursive: true,
50
+ withFileTypes: true
51
+ })) {
52
+ const dest = join(targetDir, relative(source, join(entry.parentPath, entry.name)).split(sep).map((part) => part === "_gitignore" ? ".gitignore" : part).join(sep));
53
+ if (entry.isDirectory()) {
54
+ mkdirSync(dest, { recursive: true });
55
+ continue;
56
+ }
57
+ writeFileSync(dest, readFileSync(join(entry.parentPath, entry.name), "utf8").replaceAll("{{name}}", vars.name).replaceAll("{{slug}}", vars.slug));
58
+ }
59
+ }
60
+ //#endregion
61
+ //#region src/projects.ts
62
+ /**
63
+ * canopy create | projects | delete — the project registry from the terminal
64
+ * (docs/04-roadmap.md Phase 1 item 2). `create` registers the project, then
65
+ * scaffolds ./<slug>/ from the starter template (--no-scaffold to skip, e.g.
66
+ * for an existing directory).
67
+ */
68
+ function slugify(input) {
69
+ return input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-+|-+$)/g, "").slice(0, 40);
70
+ }
71
+ const createCommand = defineCommand({
72
+ meta: {
73
+ name: "create",
74
+ description: "Create a new Canopy project"
75
+ },
76
+ args: {
77
+ name: {
78
+ type: "positional",
79
+ description: "Project name (prompted for when omitted)",
80
+ required: false
81
+ },
82
+ slug: {
83
+ type: "string",
84
+ description: "URL slug (defaults to a slugified name)"
85
+ },
86
+ template: {
87
+ type: "string",
88
+ description: `Template to scaffold: ${TEMPLATES.map((t) => t.id).join(" | ")} (picker when omitted)`
89
+ },
90
+ scaffold: {
91
+ type: "boolean",
92
+ default: true,
93
+ description: "Scaffold ./<slug>/ from the starter template",
94
+ negativeDescription: "Only register — no files written"
95
+ }
96
+ },
97
+ run: reportErrors("create", async ({ args }) => {
98
+ let name = args._.join(" ").trim();
99
+ p.intro("canopy create");
100
+ if (!name) {
101
+ requireTty("pass the project name as an argument.");
102
+ name = unwrap(await p.text({
103
+ message: "Project name",
104
+ validate: (value) => value?.trim() ? void 0 : "A project name is required."
105
+ })).trim();
106
+ }
107
+ let slug = args.slug;
108
+ if (!slug && process.stdin.isTTY) slug = unwrap(await p.text({
109
+ message: "Slug",
110
+ defaultValue: slugify(name),
111
+ placeholder: slugify(name),
112
+ validate: (value) => !value || /^[a-z0-9][a-z0-9-]*$/.test(value) ? void 0 : "Lowercase letters, digits and dashes only."
113
+ })).trim();
114
+ slug ||= slugify(name);
115
+ let template = TEMPLATES.find((t) => t.id === (args.template ?? "static"));
116
+ if (!template) throw new Error(`Unknown template "${args.template}" — available: ${TEMPLATES.map((t) => t.id).join(", ")}.`);
117
+ if (args.scaffold && !args.template && process.stdin.isTTY) {
118
+ const id = unwrap(await p.select({
119
+ message: "Template",
120
+ options: TEMPLATES.filter((t) => !t.hidden).map((t) => ({
121
+ value: t.id,
122
+ label: t.label,
123
+ hint: t.hint
124
+ }))
125
+ }));
126
+ template = TEMPLATES.find((t) => t.id === id) ?? template;
127
+ }
128
+ if (template.hidden) p.log.warn("Per-project Workers are not deployable yet (Phase 2) — `canopy deploy` publishes the frontend only for now.");
129
+ const targetDir = resolve(slug);
130
+ if (args.scaffold && existsSync(targetDir)) throw new Error(`Directory ./${slug} already exists — remove it, or use --no-scaffold to only register.`);
131
+ const spin = p.spinner();
132
+ spin.start(`Registering ${slug}…`);
133
+ let project;
134
+ try {
135
+ ({project} = await apiRequest("POST", "/projects", {
136
+ slug,
137
+ name
138
+ }));
139
+ } catch (err) {
140
+ spin.error("Create failed.");
141
+ throw err;
142
+ }
143
+ spin.stop(`Created ${project.name} — ${projectUrl(project.slug)}`);
144
+ if (args.scaffold) {
145
+ scaffoldTemplate(template.id, targetDir, {
146
+ name,
147
+ slug
148
+ });
149
+ const steps = [
150
+ `cd ${slug}`,
151
+ ...template.needsInstall ? ["npm install"] : [],
152
+ "canopy dev (or deploy right away: canopy deploy)"
153
+ ];
154
+ p.note(steps.join("\n"), `Scaffolded ./${slug}/ (${template.id}) — next`);
155
+ p.outro(`Live URL (after first deploy): ${projectUrl(project.slug)}`);
156
+ } else p.outro("Registered without scaffolding — add a canopy.json to deploy an existing app.");
157
+ })
158
+ });
159
+ const projectsCommand = defineCommand({
160
+ meta: {
161
+ name: "projects",
162
+ description: "List your projects"
163
+ },
164
+ args: { all: {
165
+ type: "boolean",
166
+ default: false,
167
+ description: "Include projects you can't edit"
168
+ } },
169
+ run: reportErrors("projects", async ({ args }) => {
170
+ const { projects, is_admin: isAdmin } = await apiRequest("GET", "/projects");
171
+ const mine = args.all || isAdmin ? projects : projects.filter((project) => project.role);
172
+ if (mine.length === 0) {
173
+ 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}.`);
174
+ return;
175
+ }
176
+ const width = Math.max(...mine.map((project) => project.slug.length));
177
+ for (const project of mine) {
178
+ const role = project.role ?? (isAdmin ? "admin" : `owner: ${project.owner_email}`);
179
+ console.log(`${project.slug.padEnd(width)} ${project.status.padEnd(7)} ${role.padEnd(6)} ${projectUrl(project.slug)} ${project.name}`);
180
+ }
181
+ })
182
+ });
183
+ const deleteCommand = defineCommand({
184
+ meta: {
185
+ name: "delete",
186
+ description: "Delete a project (full teardown)"
187
+ },
188
+ args: {
189
+ slug: {
190
+ type: "positional",
191
+ description: "Project slug (picked from a list when omitted)",
192
+ required: false
193
+ },
194
+ yes: {
195
+ type: "boolean",
196
+ default: false,
197
+ description: "Skip the type-the-slug confirmation"
198
+ }
199
+ },
200
+ run: reportErrors("delete", async ({ args }) => {
201
+ let slug = args.slug;
202
+ if (!slug) {
203
+ requireTty("pass the project slug as an argument.");
204
+ const listing = await apiRequest("GET", "/projects");
205
+ const projects = listing.is_admin ? listing.projects : listing.projects.filter((project) => project.role === "owner");
206
+ if (projects.length === 0) throw new Error("No projects to delete.");
207
+ slug = unwrap(await p.select({
208
+ message: "Project to delete",
209
+ options: projects.map((project) => ({
210
+ value: project.slug,
211
+ label: project.slug,
212
+ hint: project.name
213
+ }))
214
+ }));
215
+ }
216
+ if (!args.yes) {
217
+ requireTty("pass --yes to delete without confirmation.");
218
+ if (unwrap(await p.text({ message: `Delete "${slug}" permanently? This tears down its database and files. Type the slug to confirm.` })).trim() !== slug) {
219
+ p.cancel("Aborted.");
220
+ return;
221
+ }
222
+ }
223
+ await apiRequest("DELETE", `/projects/${slug}`);
224
+ p.log.success(`Deleted ${slug}.`);
225
+ })
226
+ });
227
+ //#endregion
228
+ export { createCommand, deleteCommand, projectsCommand };
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: canopy
3
+ description: >-
4
+ Build, run, and deploy projects on Canopy — Monks' managed platform for
5
+ vibecoded apps (hosting + database + blob storage + AI). Use this whenever the
6
+ user wants to create a Canopy project, run/deploy one, manage its data or
7
+ deploy tokens, or asks how to use the `canopy` CLI. All platform docs are
8
+ available offline via `canopy docs`.
9
+ ---
10
+
11
+ # Canopy
12
+
13
+ Canopy is Monks' managed platform for vibecoded projects. Everything is driven
14
+ by one CLI: **`canopy`**. This skill tells you how to drive it; the CLI itself
15
+ carries the full, always-current documentation.
16
+
17
+ ## First: is the CLI installed and signed in?
18
+
19
+ Run these before anything else, in order:
20
+
21
+ ```bash
22
+ canopy --version # is the CLI installed?
23
+ canopy whoami # is there a valid login?
24
+ ```
25
+
26
+ - **`canopy --version` fails / command not found** → install it:
27
+
28
+ ```bash
29
+ npm install -g @mediamonks/canopy-cli
30
+ ```
31
+
32
+ (Needs Node.js 20+. If `npm` is missing, tell the user to install Node first.)
33
+
34
+ - **`canopy whoami` says not logged in / expired** → run:
35
+ ```bash
36
+ canopy login
37
+ ```
38
+ This opens the user's browser. **Then STOP and tell the user to complete the
39
+ sign-in in their browser.** The command waits for them and finishes on its own.
40
+ - Never ask for or type the user's password, OneLogin codes, or any
41
+ credentials yourself.
42
+ - Login only works on the user's own machine (it uses a `127.0.0.1` redirect).
43
+ If you're on a remote/headless box, tell the user — it can't complete there.
44
+ - `canopy login --json` gives machine-readable status if you need to parse it.
45
+
46
+ Skip login if `canopy whoami` already shows a valid, unexpired token.
47
+
48
+ ## Then: read the docs for whatever you're doing
49
+
50
+ The CLI ships the full documentation and keeps it fresh from the platform. List
51
+ the topics and read the relevant one _before_ running unfamiliar commands:
52
+
53
+ ```bash
54
+ canopy docs # list all topics
55
+ canopy docs deploy # print one topic
56
+ ```
57
+
58
+ Available topics include: `getting-started`, `cli` (full command reference),
59
+ `deploy`, `dev`, `data`, `ci`, `troubleshooting`. Always prefer `canopy docs`
60
+ and `canopy <command> --help` over guessing flags — they are the source of
61
+ truth and never go stale.
62
+
63
+ ## Command map (quick orientation — see `canopy docs cli` for detail)
64
+
65
+ - Create a project: `canopy create`
66
+ - Run locally: `canopy dev` (live data) or `canopy dev --local` (offline sandbox)
67
+ - Deploy: `canopy deploy` (add `--build` if the project has a build step)
68
+ - Roll back: `canopy rollback`
69
+ - Open the live site: `canopy open`
70
+ - Manage data: `canopy db pull | snapshot | snapshots | restore`
71
+ - CI credentials: `canopy token create | list | revoke`
72
+
73
+ ## Safety rules
74
+
75
+ - **Destructive commands** — `canopy db restore` and `canopy delete` wipe data.
76
+ Confirm intent with the user first, and take a `canopy db snapshot` before
77
+ restores.
78
+ - **Deploys are public.** `canopy deploy` makes a version live. Confirm before
79
+ deploying unless the user clearly asked you to.
80
+ - Keep the CLI current: `canopy upgrade` (this also refreshes this skill).