@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.
- package/dist/api-CyJ-wBZh.mjs +40 -0
- package/dist/config-C-z7st1X.mjs +33 -0
- package/dist/{db-BBcgT1md.mjs → db-CtUG4vfG.mjs} +3 -3
- package/dist/{deploy-BfhAlb91.mjs → deploy-N88l4Kt3.mjs} +7 -6
- package/dist/{dev-BB7CwqoI.mjs → dev-DTGW0lm0.mjs} +6 -5
- package/dist/dev-D_GlUULk.mjs +2 -0
- package/dist/index.mjs +24 -14
- package/dist/{local-DhufX0eJ.mjs → local-951zc9qJ.mjs} +2 -2
- package/dist/{login-DrXKOEmr.mjs → login-DsWvYc8C.mjs} +2 -1
- package/dist/{open-DFWvrLFc.mjs → open-xLiGUz_W.mjs} +2 -2
- package/dist/{project-CrrpLEPs.mjs → project-CpiPLNby.mjs} +1 -1
- package/dist/projects-DJT_mJDR.mjs +228 -0
- package/dist/templates/spa/.github/workflows/canopy-deploy.yml +34 -0
- package/dist/templates/spa/AGENTS.md +43 -0
- package/dist/templates/spa/README.md +14 -0
- package/dist/templates/spa/_gitignore +4 -0
- package/dist/templates/spa/canopy.json +5 -0
- package/dist/templates/spa/index.html +12 -0
- package/dist/templates/spa/package.json +21 -0
- package/dist/templates/spa/src/app.tsx +25 -0
- package/dist/templates/spa/src/canopy.ts +5 -0
- package/dist/templates/spa/src/main.tsx +10 -0
- package/dist/templates/spa/src/style.css +29 -0
- package/dist/templates/spa/tsconfig.json +16 -0
- package/dist/templates/spa/vite.config.ts +7 -0
- package/dist/templates/static/AGENTS.md +41 -0
- package/dist/templates/static/README.md +9 -0
- package/dist/templates/static/_gitignore +2 -0
- package/dist/templates/static/canopy.json +4 -0
- package/dist/templates/static/index.html +21 -0
- package/dist/templates/static/main.js +9 -0
- package/dist/templates/static/style.css +29 -0
- package/dist/templates/worker/.github/workflows/canopy-deploy.yml +34 -0
- package/dist/templates/worker/AGENTS.md +51 -0
- package/dist/templates/worker/README.md +18 -0
- package/dist/templates/worker/_gitignore +4 -0
- package/dist/templates/worker/canopy.json +6 -0
- package/dist/templates/worker/index.html +12 -0
- package/dist/templates/worker/package.json +22 -0
- package/dist/templates/worker/src/app.tsx +25 -0
- package/dist/templates/worker/src/canopy.ts +5 -0
- package/dist/templates/worker/src/main.tsx +10 -0
- package/dist/templates/worker/src/style.css +29 -0
- package/dist/templates/worker/tsconfig.json +16 -0
- package/dist/templates/worker/vite.config.ts +7 -0
- package/dist/templates/worker/worker/index.ts +29 -0
- package/dist/token-rTD9jxPZ.mjs +75 -0
- package/dist/ui-C8VuPzGp.mjs +40 -0
- package/dist/upgrade-TbMOjkLY.mjs +92 -0
- package/dist/version-check-CoWgQs2h.mjs +128 -0
- package/package.json +4 -4
- package/dist/api-BEY4O4ap.mjs +0 -36
- package/dist/dev-Bvy21srH.mjs +0 -2
- package/dist/projects-f_KocDae.mjs +0 -295
- package/dist/ui-Cl5P_yQ6.mjs +0 -72
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { t as readConfig } from "./config-C-z7st1X.mjs";
|
|
2
|
+
//#region src/api.ts
|
|
3
|
+
/**
|
|
4
|
+
* Authenticated requests to the Canopy control plane. Credentials, in order:
|
|
5
|
+
* CANOPY_DEPLOY_TOKEN (CI — a project-scoped deploy token, docs/02-decisions.md
|
|
6
|
+
* D11), then the CLI token from `canopy login` (~/.canopy/config.json).
|
|
7
|
+
*/
|
|
8
|
+
var ApiError = class extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
constructor(status, code, detail) {
|
|
12
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
async function apiRequest(method, path, body, rawContentType, headers) {
|
|
18
|
+
const config = readConfig();
|
|
19
|
+
const deployToken = process.env["CANOPY_DEPLOY_TOKEN"];
|
|
20
|
+
const token = deployToken || config.token;
|
|
21
|
+
if (!token) throw new Error("Not logged in — run `canopy login` first.");
|
|
22
|
+
const isForm = body instanceof FormData;
|
|
23
|
+
const response = await fetch(`${config.apiUrl}/v1${path}`, {
|
|
24
|
+
method,
|
|
25
|
+
headers: {
|
|
26
|
+
authorization: `Bearer ${token}`,
|
|
27
|
+
...body === void 0 || isForm ? {} : { "content-type": rawContentType ?? "application/json" },
|
|
28
|
+
...headers
|
|
29
|
+
},
|
|
30
|
+
body: body === void 0 ? void 0 : isForm ? body : rawContentType !== void 0 ? body : JSON.stringify(body)
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const payload = await response.json().catch(() => ({}));
|
|
34
|
+
if (response.status === 401) throw new Error(deployToken ? "Deploy token rejected — it may have been revoked. Create a new one in the dashboard (Settings → Deploy tokens) or with `canopy token create`." : "Session expired — run `canopy login` again.");
|
|
35
|
+
throw new ApiError(response.status, payload.error ?? `http_${response.status}`, payload.detail);
|
|
36
|
+
}
|
|
37
|
+
return await response.json();
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { apiRequest as t };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/config.ts
|
|
5
|
+
/**
|
|
6
|
+
* CLI config at ~/.canopy/config.json — platform endpoints and the current
|
|
7
|
+
* CLI token. Env overrides: CANOPY_API_URL, CANOPY_OIDC_ISSUER,
|
|
8
|
+
* CANOPY_OIDC_CLIENT_ID.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_API_URL = "https://canopy-api.arjan-van-wijk.workers.dev";
|
|
11
|
+
const DEFAULT_ISSUER = "https://s4capital.onelogin.com/oidc/2";
|
|
12
|
+
const DEFAULT_CLI_CLIENT_ID = "b69755d0-6678-013f-b5ce-09fe44a57aa0180501";
|
|
13
|
+
const configDir = join(homedir(), ".canopy");
|
|
14
|
+
const configPath = join(configDir, "config.json");
|
|
15
|
+
function readConfig() {
|
|
16
|
+
let stored = {};
|
|
17
|
+
try {
|
|
18
|
+
stored = JSON.parse(readFileSync(configPath, "utf8"));
|
|
19
|
+
} catch {}
|
|
20
|
+
return {
|
|
21
|
+
...stored,
|
|
22
|
+
apiUrl: process.env["CANOPY_API_URL"] ?? stored.apiUrl ?? DEFAULT_API_URL,
|
|
23
|
+
issuer: process.env["CANOPY_OIDC_ISSUER"] ?? stored.issuer ?? DEFAULT_ISSUER,
|
|
24
|
+
cliClientId: process.env["CANOPY_OIDC_CLIENT_ID"] ?? stored.cliClientId ?? DEFAULT_CLI_CLIENT_ID
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function writeConfig(config) {
|
|
28
|
+
mkdirSync(configDir, { recursive: true });
|
|
29
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
30
|
+
chmodSync(configPath, 384);
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { writeConfig as n, readConfig as t };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { i as
|
|
1
|
+
import { t as apiRequest } from "./api-CyJ-wBZh.mjs";
|
|
2
|
+
import { i as resolveProject, n as projectFlagArgs } from "./project-CpiPLNby.mjs";
|
|
3
|
+
import { i as unwrap, n as reportErrors, r as requireTty } from "./ui-C8VuPzGp.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as
|
|
3
|
-
import {
|
|
1
|
+
import { t as apiRequest } from "./api-CyJ-wBZh.mjs";
|
|
2
|
+
import { a as tryReadCanopyJson, i as resolveProject, n as projectFlagArgs, r as projectUrl, t as projectDirArgs } from "./project-CpiPLNby.mjs";
|
|
3
|
+
import { i as unwrap, n as reportErrors } from "./ui-C8VuPzGp.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
5
6
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
-
import * as p from "@clack/prompts";
|
|
7
7
|
import { join, relative, resolve } from "node:path";
|
|
8
|
-
import
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
9
9
|
//#region src/deploy.ts
|
|
10
10
|
/**
|
|
11
11
|
* canopy deploy — build (optional), bundle the output directory as multipart
|
|
@@ -17,6 +17,7 @@ import { spawn } from "node:child_process";
|
|
|
17
17
|
*/
|
|
18
18
|
const IGNORED = /* @__PURE__ */ new Set([
|
|
19
19
|
"canopy.json",
|
|
20
|
+
"AGENTS.md",
|
|
20
21
|
"CLAUDE.md",
|
|
21
22
|
"README.md",
|
|
22
23
|
".gitignore",
|
|
@@ -88,7 +89,7 @@ const deployCommand = defineCommand({
|
|
|
88
89
|
spin.start(`Deploying ${paths.length} files (${(total / 1024).toFixed(0)} kB) → ${slug}`);
|
|
89
90
|
let result;
|
|
90
91
|
try {
|
|
91
|
-
({deploy: result} = await apiRequest("POST", `/projects/${slug}/deploys`, form));
|
|
92
|
+
({deploy: result} = await apiRequest("POST", `/projects/${slug}/deploys`, form, void 0, process.env["GITHUB_ACTIONS"] ? { "x-canopy-source": "github-action" } : void 0));
|
|
92
93
|
} catch (err) {
|
|
93
94
|
spin.error("Deploy failed.");
|
|
94
95
|
throw err;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as apiRequest } from "./api-
|
|
3
|
-
import { i as resolveProject, t as projectDirArgs } from "./project-
|
|
1
|
+
import { t as readConfig } from "./config-C-z7st1X.mjs";
|
|
2
|
+
import { t as apiRequest } from "./api-CyJ-wBZh.mjs";
|
|
3
|
+
import { i as resolveProject, t as projectDirArgs } from "./project-CpiPLNby.mjs";
|
|
4
|
+
import { n as reportErrors } from "./ui-C8VuPzGp.mjs";
|
|
4
5
|
import { defineCommand } from "citty";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
//#region src/dev.ts
|
|
@@ -54,7 +55,7 @@ async function devRemote(flags, slug) {
|
|
|
54
55
|
async function devLocal(flags, slug) {
|
|
55
56
|
let dump;
|
|
56
57
|
if (flags.pull) {
|
|
57
|
-
const { fetchProjectDump } = await import("./local-
|
|
58
|
+
const { fetchProjectDump } = await import("./local-951zc9qJ.mjs");
|
|
58
59
|
console.log(`Pulling a fresh export of ${slug}…`);
|
|
59
60
|
dump = await fetchProjectDump(slug);
|
|
60
61
|
}
|
|
@@ -68,7 +69,7 @@ async function devLocal(flags, slug) {
|
|
|
68
69
|
} catch (err) {
|
|
69
70
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${err instanceof Error ? err.message : String(err)}).`);
|
|
70
71
|
}
|
|
71
|
-
const { createSandbox } = await import("./local-
|
|
72
|
+
const { createSandbox } = await import("./local-951zc9qJ.mjs");
|
|
72
73
|
const sandbox = await createSandbox({
|
|
73
74
|
slug,
|
|
74
75
|
persistDir: join(flags.dir, ".canopy", "local"),
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { i as runRefresh, r as notifyOfUpdate } from "./version-check-CoWgQs2h.mjs";
|
|
2
3
|
import { defineCommand, runMain } from "citty";
|
|
3
4
|
//#region package.json
|
|
4
|
-
var version = "0.0
|
|
5
|
+
var version = "0.1.0";
|
|
5
6
|
//#endregion
|
|
6
7
|
//#region src/index.ts
|
|
7
8
|
/**
|
|
@@ -13,10 +14,15 @@ var version = "0.0.1";
|
|
|
13
14
|
* create scaffold + register a project [phase 1]
|
|
14
15
|
* dev [--local [--pull]] local dev: remote-first, or offline sandbox [phase 1/2]
|
|
15
16
|
* deploy build & deploy assets (+ worker) [phase 1/2]
|
|
17
|
+
* token create|list|revoke deploy tokens for CI [phase 2]
|
|
16
18
|
* db pull|snapshot|restore [phase 2]
|
|
17
19
|
* logs tail the project's worker logs [phase 2]
|
|
18
20
|
* open open the deployed project / dashboard [phase 1]
|
|
19
21
|
*/
|
|
22
|
+
if (process.env["CANOPY_UPDATE_REFRESH"] === "1") {
|
|
23
|
+
await runRefresh();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
20
26
|
/** A roadmap command that has not landed yet (docs/04-roadmap.md). */
|
|
21
27
|
function planned(name, description, phase) {
|
|
22
28
|
return defineCommand({
|
|
@@ -30,26 +36,30 @@ function planned(name, description, phase) {
|
|
|
30
36
|
}
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
|
-
|
|
39
|
+
const main = defineCommand({
|
|
34
40
|
meta: {
|
|
35
41
|
name: "canopy",
|
|
36
42
|
version,
|
|
37
43
|
description: "canopy — create and deploy vibecoded projects"
|
|
38
44
|
},
|
|
39
45
|
subCommands: {
|
|
40
|
-
login: () => import("./login-
|
|
41
|
-
whoami: () => import("./login-
|
|
42
|
-
logout: () => import("./login-
|
|
43
|
-
create: () => import("./projects-
|
|
44
|
-
projects: () => import("./projects-
|
|
45
|
-
delete: () => import("./projects-
|
|
46
|
-
dev: () => import("./dev-
|
|
47
|
-
deploy: () => import("./deploy-
|
|
48
|
-
rollback: () => import("./deploy-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
login: () => import("./login-DsWvYc8C.mjs").then((m) => m.loginCommand),
|
|
47
|
+
whoami: () => import("./login-DsWvYc8C.mjs").then((m) => m.whoamiCommand),
|
|
48
|
+
logout: () => import("./login-DsWvYc8C.mjs").then((m) => m.logoutCommand),
|
|
49
|
+
create: () => import("./projects-DJT_mJDR.mjs").then((m) => m.createCommand),
|
|
50
|
+
projects: () => import("./projects-DJT_mJDR.mjs").then((m) => m.projectsCommand),
|
|
51
|
+
delete: () => import("./projects-DJT_mJDR.mjs").then((m) => m.deleteCommand),
|
|
52
|
+
dev: () => import("./dev-D_GlUULk.mjs").then((m) => m.devCommand),
|
|
53
|
+
deploy: () => import("./deploy-N88l4Kt3.mjs").then((m) => m.deployCommand),
|
|
54
|
+
rollback: () => import("./deploy-N88l4Kt3.mjs").then((m) => m.rollbackCommand),
|
|
55
|
+
token: () => import("./token-rTD9jxPZ.mjs").then((m) => m.tokenCommand),
|
|
56
|
+
db: () => import("./db-CtUG4vfG.mjs").then((m) => m.dbCommand),
|
|
57
|
+
open: () => import("./open-xLiGUz_W.mjs").then((m) => m.openCommand),
|
|
58
|
+
upgrade: () => import("./upgrade-TbMOjkLY.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-
|
|
2
|
-
import { n as rewriteApiPath } from "./dev-
|
|
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 {
|
|
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";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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,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,34 @@
|
|
|
1
|
+
# Deploy to Canopy on every push to main. GitHub's runners are the build
|
|
2
|
+
# farm — the platform owns no build infrastructure.
|
|
3
|
+
#
|
|
4
|
+
# Needs a CANOPY_DEPLOY_TOKEN repo secret: create a deploy token in the
|
|
5
|
+
# dashboard (project → Settings → Deploy tokens) or with `canopy token
|
|
6
|
+
# create`, then add it via `gh secret set CANOPY_DEPLOY_TOKEN` or the repo's
|
|
7
|
+
# Settings → Secrets. Until the secret is set, this workflow skips itself
|
|
8
|
+
# instead of failing the build.
|
|
9
|
+
name: Deploy to Canopy
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
branches: [main]
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
deploy:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
env:
|
|
19
|
+
CANOPY_DEPLOY_TOKEN: ${{ secrets.CANOPY_DEPLOY_TOKEN }}
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: 22
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
if: env.CANOPY_DEPLOY_TOKEN != ''
|
|
27
|
+
run: |
|
|
28
|
+
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
|
29
|
+
- name: Deploy
|
|
30
|
+
if: env.CANOPY_DEPLOY_TOKEN != ''
|
|
31
|
+
run: npm exec --yes @mediamonks/canopy-cli deploy
|
|
32
|
+
- name: Skipped (no deploy token)
|
|
33
|
+
if: env.CANOPY_DEPLOY_TOKEN == ''
|
|
34
|
+
run: echo "CANOPY_DEPLOY_TOKEN secret not set — skipping deploy."
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# {{name}} — a Canopy React SPA
|
|
2
|
+
|
|
3
|
+
A Vite + React single-page app on [Canopy](https://dashboard.thanarie.dev),
|
|
4
|
+
Monks' managed platform for internal projects. Everything is served behind
|
|
5
|
+
company login; there is no infrastructure to manage and none should be added.
|
|
6
|
+
|
|
7
|
+
## Canopy skill
|
|
8
|
+
|
|
9
|
+
The full SDK reference (auth, db, blob, ai) and platform guardrails ship as an
|
|
10
|
+
agent skill, kept out of this scaffold so it stays current. Install it before
|
|
11
|
+
building features:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npx skills add mediamonks/canopy
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If that fails the skill isn't published yet — the SDK surface is documented on
|
|
18
|
+
the [Canopy dashboard](https://dashboard.thanarie.dev).
|
|
19
|
+
|
|
20
|
+
## Working on this project
|
|
21
|
+
|
|
22
|
+
- A standard Vite + React + TypeScript app; `npm install` once, then develop
|
|
23
|
+
with `canopy dev` (it runs Vite with this project's `vite.config.ts` and
|
|
24
|
+
proxies `/_api` to the platform).
|
|
25
|
+
- The SDK comes from npm (`@mediamonks/canopy`) — the shared instance lives in
|
|
26
|
+
`src/canopy.ts`.
|
|
27
|
+
- `canopy dev` uses LIVE project data: read freely, but take a
|
|
28
|
+
`canopy db snapshot` before risky data changes.
|
|
29
|
+
- `canopy dev --local [--pull]` — offline sandbox with local data instead;
|
|
30
|
+
writes never reach the platform. Prefer it for destructive experiments.
|
|
31
|
+
- `canopy deploy` — builds (`npm run build`) and publishes `dist/`; live
|
|
32
|
+
(behind company login) at https://{{slug}}.thanarie.dev.
|
|
33
|
+
|
|
34
|
+
## Boundaries
|
|
35
|
+
|
|
36
|
+
- Do not add auth — the platform authenticates every request before it
|
|
37
|
+
reaches this app.
|
|
38
|
+
- Do not store secrets anywhere in this project, and never call external APIs
|
|
39
|
+
that need them from the browser — all backend needs go through the Canopy
|
|
40
|
+
SDK (`canopy.auth`, `canopy.db`, `canopy.blob`, `canopy.ai`).
|
|
41
|
+
- Frontend only: no SSR, no custom servers, no databases outside `canopy.db`.
|
|
42
|
+
(Need backend code? Recreate on the `worker` template, or ask the Canopy
|
|
43
|
+
team.)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A [Canopy](https://dashboard.thanarie.dev) project — Vite + React. Live
|
|
4
|
+
(behind company login) at https://{{slug}}.thanarie.dev after the first
|
|
5
|
+
`canopy deploy`.
|
|
6
|
+
|
|
7
|
+
- `npm install` — once, then:
|
|
8
|
+
- `canopy dev` — develop locally against the live platform (real data!)
|
|
9
|
+
- `canopy dev --local [--pull]` — offline sandbox with local data instead
|
|
10
|
+
- `canopy deploy` — build and publish `dist/`
|
|
11
|
+
- `AGENTS.md` — project guardrails and skill setup for coding agents
|
|
12
|
+
|
|
13
|
+
Push-to-main deploys are prepared in `.github/workflows/canopy-deploy.yml`
|
|
14
|
+
(needs a `CANOPY_DEPLOY_TOKEN` repo secret — see the workflow file).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{slug}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "canopy dev",
|
|
7
|
+
"build": "tsc && vite build"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@mediamonks/canopy": "^0.0.1",
|
|
11
|
+
"react": "^19.1.0",
|
|
12
|
+
"react-dom": "^19.1.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/react": "^19.1.0",
|
|
16
|
+
"@types/react-dom": "^19.1.0",
|
|
17
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
18
|
+
"typescript": "^5.8.0",
|
|
19
|
+
"vite": "^7.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { canopy } from "./canopy.ts";
|
|
3
|
+
|
|
4
|
+
export function App() {
|
|
5
|
+
const [status, setStatus] = useState("connecting to Canopy…");
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
canopy.auth
|
|
9
|
+
.user()
|
|
10
|
+
.then((user) => setStatus(`signed in as ${user.email}`))
|
|
11
|
+
.catch(() => setStatus("could not reach Canopy — is `canopy dev` running?"));
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<main>
|
|
16
|
+
<h1>{"{{name}}"}</h1>
|
|
17
|
+
<p className="status">{status}</p>
|
|
18
|
+
<section>
|
|
19
|
+
<p className="placeholder">
|
|
20
|
+
Start building — edit <code>src/app.tsx</code>, then run <code>canopy deploy</code>.
|
|
21
|
+
</p>
|
|
22
|
+
</section>
|
|
23
|
+
</main>
|
|
24
|
+
);
|
|
25
|
+
}
|