@mediamonks/canopy-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-BEY4O4ap.mjs +36 -0
- package/dist/db-BBcgT1md.mjs +151 -0
- package/dist/deploy-BfhAlb91.mjs +152 -0
- package/dist/dev-BB7CwqoI.mjs +170 -0
- package/dist/dev-Bvy21srH.mjs +2 -0
- package/dist/index.mjs +55 -0
- package/dist/local-DhufX0eJ.mjs +563 -0
- package/dist/login-DrXKOEmr.mjs +146 -0
- package/dist/open-DFWvrLFc.mjs +23 -0
- package/dist/project-CrrpLEPs.mjs +54 -0
- package/dist/projects-f_KocDae.mjs +295 -0
- package/dist/ui-Cl5P_yQ6.mjs +72 -0
- package/package.json +36 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { a as readConfig, n as reportErrors, o as writeConfig, t as openBrowser } from "./ui-Cl5P_yQ6.mjs";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
//#region src/login.ts
|
|
7
|
+
/**
|
|
8
|
+
* canopy login | whoami | logout — browser PKCE flow against OneLogin (public
|
|
9
|
+
* client), then trade the ID token for a platform CLI token via POST
|
|
10
|
+
* /v1/auth/cli-token. See docs/03-architecture.md "CLI auth".
|
|
11
|
+
*/
|
|
12
|
+
const CALLBACK_PORT = 8976;
|
|
13
|
+
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
|
|
14
|
+
const LOGIN_TIMEOUT_MS = 300 * 1e3;
|
|
15
|
+
const base64url = (buf) => buf.toString("base64url");
|
|
16
|
+
const whoamiCommand = defineCommand({
|
|
17
|
+
meta: {
|
|
18
|
+
name: "whoami",
|
|
19
|
+
description: "Show the current Canopy login"
|
|
20
|
+
},
|
|
21
|
+
run: reportErrors("whoami", async () => {
|
|
22
|
+
const config = readConfig();
|
|
23
|
+
if (!config.token) throw new Error("Not logged in — run `canopy login`.");
|
|
24
|
+
console.log(`${config.email ?? "<unknown email>"} — ${config.apiUrl}`);
|
|
25
|
+
if (config.tokenExpiresAt !== void 0) {
|
|
26
|
+
const expired = Date.parse(config.tokenExpiresAt) < Date.now();
|
|
27
|
+
console.log(expired ? `Token EXPIRED ${config.tokenExpiresAt} — run \`canopy login\` again.` : `Token expires ${config.tokenExpiresAt}.`);
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
const logoutCommand = defineCommand({
|
|
32
|
+
meta: {
|
|
33
|
+
name: "logout",
|
|
34
|
+
description: "Sign out: forget the local CLI token"
|
|
35
|
+
},
|
|
36
|
+
run: reportErrors("logout", async () => {
|
|
37
|
+
const config = readConfig();
|
|
38
|
+
if (!config.token) {
|
|
39
|
+
console.log("Not logged in.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const next = { ...config };
|
|
43
|
+
delete next.token;
|
|
44
|
+
delete next.tokenExpiresAt;
|
|
45
|
+
delete next.email;
|
|
46
|
+
writeConfig(next);
|
|
47
|
+
console.log(`Logged out${config.email ? ` (${config.email})` : ""} — token removed from ~/.canopy/config.json.`);
|
|
48
|
+
})
|
|
49
|
+
});
|
|
50
|
+
/** Wait for OneLogin to redirect back to the local loopback server. */
|
|
51
|
+
function waitForCallback(expectedState) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const server = createServer((req, res) => {
|
|
54
|
+
const url = new URL(req.url ?? "/", REDIRECT_URI);
|
|
55
|
+
if (url.pathname !== "/callback") {
|
|
56
|
+
res.writeHead(404).end();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const code = url.searchParams.get("code");
|
|
60
|
+
const state = url.searchParams.get("state");
|
|
61
|
+
const ok = Boolean(code) && state === expectedState;
|
|
62
|
+
res.writeHead(ok ? 200 : 400, { "content-type": "text/html" }).end(ok ? "<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>" : "<h1>Login failed</h1><p>Return to the terminal and try again.</p>");
|
|
63
|
+
server.close();
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
if (ok) resolve(code);
|
|
66
|
+
else reject(new Error(url.searchParams.get("error_description") ?? "Login rejected"));
|
|
67
|
+
});
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
server.close();
|
|
70
|
+
reject(/* @__PURE__ */ new Error("Timed out waiting for the browser login (5 minutes)."));
|
|
71
|
+
}, LOGIN_TIMEOUT_MS);
|
|
72
|
+
server.on("error", (err) => reject(err.message.includes("EADDRINUSE") ? /* @__PURE__ */ new Error(`Port ${CALLBACK_PORT} is in use — close the other process and retry.`) : err));
|
|
73
|
+
server.listen(CALLBACK_PORT, "127.0.0.1");
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const loginCommand = defineCommand({
|
|
77
|
+
meta: {
|
|
78
|
+
name: "login",
|
|
79
|
+
description: "Sign in to Canopy via OneLogin"
|
|
80
|
+
},
|
|
81
|
+
run: reportErrors("login", () => login())
|
|
82
|
+
});
|
|
83
|
+
async function login() {
|
|
84
|
+
p.intro("canopy login");
|
|
85
|
+
const config = readConfig();
|
|
86
|
+
const { issuer, cliClientId, apiUrl } = config;
|
|
87
|
+
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).");
|
|
88
|
+
const discoveryRes = await fetch(`${issuer}/.well-known/openid-configuration`);
|
|
89
|
+
if (!discoveryRes.ok) throw new Error(`OIDC discovery failed: ${discoveryRes.status}`);
|
|
90
|
+
const discovery = await discoveryRes.json();
|
|
91
|
+
const state = base64url(randomBytes(32));
|
|
92
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
93
|
+
const authorizeUrl = new URL(discovery.authorization_endpoint);
|
|
94
|
+
authorizeUrl.search = new URLSearchParams({
|
|
95
|
+
response_type: "code",
|
|
96
|
+
client_id: cliClientId,
|
|
97
|
+
redirect_uri: REDIRECT_URI,
|
|
98
|
+
scope: "openid profile email",
|
|
99
|
+
state,
|
|
100
|
+
code_challenge: base64url(createHash("sha256").update(codeVerifier).digest()),
|
|
101
|
+
code_challenge_method: "S256"
|
|
102
|
+
}).toString();
|
|
103
|
+
const authorizeHref = authorizeUrl.toString();
|
|
104
|
+
p.note(`If nothing opens, visit:\n${authorizeHref}`, "Opening OneLogin in your browser");
|
|
105
|
+
openBrowser(authorizeHref);
|
|
106
|
+
const spin = p.spinner();
|
|
107
|
+
spin.start("Waiting for you to sign in in the browser…");
|
|
108
|
+
let code;
|
|
109
|
+
try {
|
|
110
|
+
code = await waitForCallback(state);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
spin.error("Sign-in failed.");
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
spin.stop("Signed in with OneLogin.");
|
|
116
|
+
const tokenRes = await fetch(discovery.token_endpoint, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
119
|
+
body: new URLSearchParams({
|
|
120
|
+
grant_type: "authorization_code",
|
|
121
|
+
client_id: cliClientId,
|
|
122
|
+
code,
|
|
123
|
+
redirect_uri: REDIRECT_URI,
|
|
124
|
+
code_verifier: codeVerifier
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
|
|
128
|
+
const { id_token: idToken } = await tokenRes.json();
|
|
129
|
+
if (!idToken) throw new Error("OneLogin returned no id_token.");
|
|
130
|
+
const cliTokenRes = await fetch(`${apiUrl}/v1/auth/cli-token`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ id_token: idToken })
|
|
134
|
+
});
|
|
135
|
+
if (!cliTokenRes.ok) throw new Error(`Canopy rejected the login: ${cliTokenRes.status} ${await cliTokenRes.text()}`);
|
|
136
|
+
const { token, expires_at, user } = await cliTokenRes.json();
|
|
137
|
+
writeConfig({
|
|
138
|
+
...config,
|
|
139
|
+
token,
|
|
140
|
+
tokenExpiresAt: expires_at,
|
|
141
|
+
email: user.email
|
|
142
|
+
});
|
|
143
|
+
p.outro(`Logged in as ${user.name} <${user.email}> (token expires ${expires_at}).`);
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
export { loginCommand, logoutCommand, whoamiCommand };
|
|
@@ -0,0 +1,23 @@
|
|
|
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";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
//#region src/open.ts
|
|
5
|
+
/**
|
|
6
|
+
* canopy open — open the deployed project in the browser
|
|
7
|
+
* (docs/04-roadmap.md Phase 1).
|
|
8
|
+
*/
|
|
9
|
+
const openCommand = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: "open",
|
|
12
|
+
description: "Open the deployed project in your browser"
|
|
13
|
+
},
|
|
14
|
+
args: projectDirArgs,
|
|
15
|
+
run: reportErrors("open", async ({ args }) => {
|
|
16
|
+
const { slug } = resolveProject(args);
|
|
17
|
+
const url = projectUrl(slug);
|
|
18
|
+
console.log(`Opening ${url}`);
|
|
19
|
+
openBrowser(url);
|
|
20
|
+
})
|
|
21
|
+
});
|
|
22
|
+
//#endregion
|
|
23
|
+
export { openCommand };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { a as readConfig } from "./ui-Cl5P_yQ6.mjs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
//#region src/project.ts
|
|
5
|
+
/**
|
|
6
|
+
* Which project is a command about? Every project-scoped command resolves it
|
|
7
|
+
* the same way: `--project <slug>` wins, otherwise canopy.json in the target
|
|
8
|
+
* directory (the optional [dir] positional, defaulting to the cwd).
|
|
9
|
+
*/
|
|
10
|
+
/** The wildcard apex projects are served under, derived from the API origin's registrable domain. */
|
|
11
|
+
function projectUrl(slug) {
|
|
12
|
+
const apiHost = new URL(readConfig().apiUrl).hostname;
|
|
13
|
+
return `https://${slug}.${apiHost.endsWith("workers.dev") ? "thanarie.dev" : apiHost.split(".").slice(-2).join(".")}`;
|
|
14
|
+
}
|
|
15
|
+
/** canopy.json parsed if present; undefined when the file is missing. */
|
|
16
|
+
function tryReadCanopyJson(dir) {
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = readFileSync(join(dir, "canopy.json"), "utf8");
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
function readCanopyJson(dir) {
|
|
26
|
+
const config = tryReadCanopyJson(dir);
|
|
27
|
+
if (config === void 0) throw new Error(`No canopy.json in ${dir} — run from a project created by \`canopy create\`, add one ({"slug": "<slug>", "dist": "dist"}), or pass --project <slug>.`);
|
|
28
|
+
if (!config.slug) throw new Error("canopy.json is missing \"slug\".");
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
/** For commands scoped to a project but not to a directory (db, rollback). */
|
|
32
|
+
const projectFlagArgs = { project: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "Project slug (overrides canopy.json in the target directory)"
|
|
35
|
+
} };
|
|
36
|
+
/** For commands that operate on a project directory (dev, deploy, open). */
|
|
37
|
+
const projectDirArgs = {
|
|
38
|
+
dir: {
|
|
39
|
+
type: "positional",
|
|
40
|
+
description: "Project directory (defaults to the current directory)",
|
|
41
|
+
required: false
|
|
42
|
+
},
|
|
43
|
+
...projectFlagArgs
|
|
44
|
+
};
|
|
45
|
+
/** Resolve the target directory and project slug from the shared args. */
|
|
46
|
+
function resolveProject(args) {
|
|
47
|
+
const dir = resolve(args.dir ?? ".");
|
|
48
|
+
return {
|
|
49
|
+
dir,
|
|
50
|
+
slug: args.project ?? readCanopyJson(dir).slug
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { tryReadCanopyJson as a, resolveProject as i, projectFlagArgs as n, projectUrl as r, projectDirArgs as t };
|
|
@@ -0,0 +1,295 @@
|
|
|
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 };
|
|
@@ -0,0 +1,72 @@
|
|
|
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 };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mediamonks/canopy-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Canopy CLI — create, develop and deploy Canopy projects.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"canopy": "./dist/index.mjs"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./dist/index.mjs",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@clack/prompts": "^1.7.0",
|
|
18
|
+
"citty": "^0.2.2",
|
|
19
|
+
"hono": "^4.8.0",
|
|
20
|
+
"miniflare": "^4.20260714.0",
|
|
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"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^26.1.1",
|
|
27
|
+
"typescript": "^7.0.2",
|
|
28
|
+
"vite-plus": "0.2.5"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "vp pack",
|
|
32
|
+
"dev": "vp pack --watch",
|
|
33
|
+
"test": "vp test",
|
|
34
|
+
"check": "vp check"
|
|
35
|
+
}
|
|
36
|
+
}
|