@calo-design/cli 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/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @calo-design/cli — one-line Calo design setup
2
+
3
+ A tiny **installer** (not a runtime). It logs you in with your Calo email, sets up the
4
+ [`calo-design`](https://github.com/Calo-Design/calo-design) Claude Code skill and the
5
+ design-system packages, then gets out of the way — the Claude Code agent is the
6
+ runtime, the skill is the guidance.
7
+
8
+ **No GitHub account, PAT, or SSH key needed.** Access is gated by your `@calo.app`
9
+ email via the Calo broker, which mints a short-lived GitHub token at install time.
10
+
11
+ ## Use
12
+
13
+ ```bash
14
+ npx @calo-design/cli login # one-time: enter your Calo email + the emailed code
15
+ npx @calo-design/cli init # skill + @calo/design-system + @calo/flows + RN peers
16
+ ```
17
+
18
+ Then start a new Claude Code session and ask for a screen
19
+ (e.g. *"build a Calo-native meal-swap screen"*).
20
+
21
+ ```bash
22
+ npx @calo-design/cli init --skip-packages # skill only
23
+ npx @calo-design/cli update # re-install latest skill + re-pin the shared runtime
24
+ npx @calo-design/cli logout # forget the saved session
25
+ ```
26
+
27
+ The login session is stored at `~/.designchef/session.json` (chmod 600) and refreshes
28
+ automatically. Override the broker with `CALO_BROKER_URL` (used for local dev).
29
+
30
+ Fonts ship with `@calo/design-system`. Load them in your root layout:
31
+
32
+ ```tsx
33
+ import { useFonts } from "expo-font";
34
+ import { caloFonts } from "@calo/design-system";
35
+ const [fontsLoaded] = useFonts(caloFonts);
36
+ ```
37
+
38
+ ## How access works
39
+
40
+ ```
41
+ npx @calo-design/cli login → broker verifies @calo.app + emails OTP → session JWT (~/.designchef)
42
+ npx @calo-design/cli init → broker mints a 1h read-only GitHub token → private npm installs
43
+ ```
44
+
45
+ The broker (`../calo-broker`) is the only place the GitHub / Expo / Tigris secrets
46
+ live. See its README for the one-time provisioning checklist.
47
+
48
+ ## Tests
49
+
50
+ ```bash
51
+ # with a dev broker running (cd ../calo-broker && npm run dev):
52
+ BASE=http://localhost:8080 bash test/client-spine.sh
53
+ ```
package/bin/cli.js ADDED
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * calo-design — one-time setup (NOT a runtime).
6
+ * Installs (1) the calo-design Claude Code skill into ~/.claude/skills, and
7
+ * (2) the Calo prototyping stack (@calo/design-system + @calo/flows + RN peers)
8
+ * ONCE into a shared runtime at ~/.designchef/runtime. Each prototype is a thin
9
+ * Expo project whose node_modules is a symlink to that shared runtime, so there is
10
+ * one copy on disk and `calo-design update` refreshes every prototype at once.
11
+ * The Claude Code agent is the runtime; the skill is the guidance.
12
+ */
13
+
14
+ const { spawnSync } = require("node:child_process");
15
+ const fs = require("node:fs");
16
+ const os = require("node:os");
17
+ const path = require("node:path");
18
+ const { cmdPush } = require("./mirror-push");
19
+ const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken } = require("./login");
20
+
21
+ const ORG = "Calo-Design";
22
+ const SKILL_REPO = `${ORG}/calo-design`;
23
+ const PEERS = [
24
+ "react-native-svg",
25
+ "phosphor-react-native",
26
+ "@gorhom/bottom-sheet",
27
+ "react-native-safe-area-context",
28
+ "react-native-gesture-handler",
29
+ "react-native-reanimated",
30
+ "react-native-worklets",
31
+ // The Mirror loads each prototype as an EAS Update at runtime; prototypes need
32
+ // the expo-updates JS API (the "back to Mirror" chrome `push` injects calls it).
33
+ // The shell binary provides the native module; prototypes ship JS only.
34
+ "expo-updates"
35
+ ];
36
+ // Install via explicit HTTPS git URLs. Auth is a short-lived GitHub token the Calo
37
+ // broker mints after `calo-design login` — injected into git for the install
38
+ // subprocess only (see gitTokenEnv). No GitHub account, PAT, or SSH key needed.
39
+ const PKG_SPECS = [
40
+ `git+https://github.com/${ORG}/calo-design-system.git`,
41
+ `git+https://github.com/${ORG}/calo-flows.git`
42
+ ];
43
+
44
+ const args = process.argv.slice(2);
45
+ const cmd = args[0] || "help";
46
+ const has = (flag) => args.includes(flag);
47
+
48
+ const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m` };
49
+ const log = (s = "") => console.log(s);
50
+ const ok = (s) => log(`${c.g("✓")} ${s}`);
51
+ const warn = (s) => log(`${c.y("!")} ${s}`);
52
+ const tilde = (p) => p.replace(os.homedir(), "~");
53
+
54
+ function run(bin, argv, opts = {}) {
55
+ const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
56
+ if (r.error) throw r.error;
57
+ if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
58
+ return r;
59
+ }
60
+ function sleep(sec) {
61
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, sec * 1000); } catch {}
62
+ }
63
+ // Private-repo git fetches over https are occasionally flaky (credential-helper
64
+ // races / rate limits) and recover on retry. Retry before giving up.
65
+ function runWithRetry(bin, argv, tries = 3, opts = {}) {
66
+ for (let i = 1; i <= tries; i++) {
67
+ const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
68
+ if (!r.error && r.status === 0) return r;
69
+ if (i < tries) { warn(`install hiccup — retrying (${i}/${tries - 1})…`); sleep(3); }
70
+ }
71
+ throw new Error(`${bin} ${argv.join(" ")} failed after ${tries} attempts`);
72
+ }
73
+ // Build an env that makes git authenticate github.com with a broker-minted token,
74
+ // for the spawned subprocess ONLY. Uses git's GIT_CONFIG_* env override so nothing
75
+ // is written to ~/.gitconfig and the token is never persisted to disk.
76
+ function gitTokenEnv(token, base = process.env) {
77
+ // Append at the next free GIT_CONFIG index so we don't clobber any the caller set.
78
+ const n = parseInt(base.GIT_CONFIG_COUNT || "0", 10) || 0;
79
+ return {
80
+ ...base,
81
+ GIT_CONFIG_COUNT: String(n + 1),
82
+ [`GIT_CONFIG_KEY_${n}`]: `url.https://x-access-token:${token}@github.com/.insteadOf`,
83
+ [`GIT_CONFIG_VALUE_${n}`]: "https://github.com/",
84
+ GIT_TERMINAL_PROMPT: "0"
85
+ };
86
+ }
87
+
88
+ // ---------------------------------------------------------------- skill (global)
89
+
90
+ async function cloneSkillRepo(tmp) {
91
+ // Authenticate the clone with a short-lived broker-minted GitHub token, injected
92
+ // into git for this subprocess only. No gh, PAT, or SSH key required.
93
+ const token = await githubToken();
94
+ run("git", ["clone", "--depth", "1", "-q", `https://github.com/${SKILL_REPO}.git`, tmp], { env: gitTokenEnv(token) });
95
+ }
96
+
97
+ async function installSkill() {
98
+ log(c.b("\n[skill] Installing the calo-design skill"));
99
+ const home = process.env.CALO_HOME || os.homedir();
100
+ const dest = path.join(home, ".claude", "skills", "calo-design");
101
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "calo-skill-"));
102
+ try {
103
+ await cloneSkillRepo(tmp);
104
+ const src = path.join(tmp, "skills", "calo-design");
105
+ if (!fs.existsSync(path.join(src, "SKILL.md"))) throw new Error("skill not found in repo");
106
+ fs.rmSync(dest, { recursive: true, force: true });
107
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
108
+ fs.cpSync(src, dest, { recursive: true });
109
+ ok(`skill -> ${dest.replace(home, "~")}`);
110
+ log(c.dim(" Live in your current session (no restart) — invoke with /calo-design, or just ask."));
111
+ } finally {
112
+ fs.rmSync(tmp, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------- shared runtime
117
+
118
+ function designchefHome() {
119
+ return process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
120
+ }
121
+ function runtimeDir() { return path.join(designchefHome(), "runtime"); }
122
+ function runtimeManifestPath() { return path.join(runtimeDir(), ".calo-runtime.json"); }
123
+ function runtimeExists() {
124
+ const rt = runtimeDir();
125
+ return (
126
+ fs.existsSync(runtimeManifestPath()) &&
127
+ fs.existsSync(path.join(rt, "node_modules", "@calo", "design-system", "package.json")) &&
128
+ fs.existsSync(path.join(rt, "node_modules", "@calo", "flows", "package.json")) &&
129
+ fs.existsSync(path.join(rt, "node_modules", "expo", "package.json"))
130
+ );
131
+ }
132
+
133
+ async function ensureRuntime({ force } = {}) {
134
+ const rt = runtimeDir();
135
+ if (!force && runtimeExists()) { ok(`shared runtime ready (${tilde(rt)})`); return; }
136
+ log(c.b("\n[runtime] Building the shared Calo runtime (one-time, ~a couple of minutes)"));
137
+ fs.mkdirSync(rt, { recursive: true });
138
+ if (force) {
139
+ fs.rmSync(path.join(rt, "node_modules"), { recursive: true, force: true });
140
+ try { fs.rmSync(runtimeManifestPath()); } catch {}
141
+ }
142
+ if (!fs.existsSync(path.join(rt, "package.json"))) {
143
+ if (!scaffoldExpoApp(rt)) throw new Error("runtime scaffold failed");
144
+ }
145
+ await installCaloDeps(rt);
146
+ fs.writeFileSync(
147
+ runtimeManifestPath(),
148
+ JSON.stringify({ builtAt: new Date().toISOString(), pkgSpecs: PKG_SPECS, peers: PEERS }, null, 2) + "\n"
149
+ );
150
+ ok(`shared runtime built -> ${tilde(rt)}`);
151
+ }
152
+
153
+ // ---------------------------------------------------------------- shared helpers
154
+
155
+ function isExpoProject(cwd = process.cwd()) {
156
+ try {
157
+ const pj = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
158
+ return Boolean((pj.dependencies && pj.dependencies.expo) || (pj.devDependencies && pj.devDependencies.expo));
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+ function hasPackageJson(cwd = process.cwd()) { return fs.existsSync(path.join(cwd, "package.json")); }
164
+ function dirIsScaffoldable(cwd = process.cwd()) {
165
+ // Effectively empty: only junk (dotfiles, *.log, README/LICENSE, Thumbs.db) is here.
166
+ try {
167
+ const junk = /^\.|\.log$|^readme(\.|$)|^license(\.|$)|^thumbs\.db$/i;
168
+ return fs.readdirSync(cwd).every((e) => junk.test(e));
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ function scaffoldExpoApp(destDir = process.cwd()) {
175
+ log(c.dim(" Scaffolding a fresh Expo app (expo-router + TypeScript)…"));
176
+ // create-expo-app refuses ANY non-empty target (even a stray firebase-debug.log),
177
+ // so scaffold into a temp dir, then merge files in without overwriting anything present.
178
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "calo-expo-"));
179
+ try {
180
+ run("npx", ["--yes", "create-expo-app@latest", tmp, "--yes", "--no-install", "--no-agents-md"]);
181
+ for (const entry of fs.readdirSync(tmp)) {
182
+ const dest = path.join(destDir, entry);
183
+ if (!fs.existsSync(dest)) fs.cpSync(path.join(tmp, entry), dest, { recursive: true });
184
+ }
185
+ ok("Expo app scaffolded");
186
+ return true;
187
+ } catch (e) {
188
+ warn(`couldn't scaffold automatically (${e.message})`);
189
+ return false;
190
+ } finally {
191
+ fs.rmSync(tmp, { recursive: true, force: true });
192
+ }
193
+ }
194
+
195
+ // Install the Calo stack + peers into `cwd` (used for the shared runtime, and for
196
+ // the per-project fallback / --isolated path). Forces HTTPS so private installs
197
+ // don't fall back to SSH. Does not print a summary — callers do.
198
+ async function installCaloDeps(cwd) {
199
+ // Private @calo/* installs authenticate with a short-lived broker-minted token,
200
+ // injected into git for the npm subprocess only (no gh / PAT / SSH key).
201
+ const env = gitTokenEnv(await githubToken());
202
+ runWithRetry("npm", ["install", ...PKG_SPECS], 3, { cwd, env });
203
+ if (isExpoProject(cwd)) run("npx", ["expo", "install", ...PEERS, "expo-font"], { cwd });
204
+ else {
205
+ warn("non-Expo project — installing latest peers; pin them to match your React Native version if needed.");
206
+ run("npm", ["install", ...PEERS], { cwd });
207
+ }
208
+ }
209
+
210
+ // Legacy per-project install (no shared runtime): scaffold here if needed, then install.
211
+ async function installHere() {
212
+ log(c.b("\n[setup] Installing the Calo stack into this project"));
213
+ if (!hasPackageJson()) {
214
+ if (!dirIsScaffoldable()) {
215
+ warn("no package.json, and the folder isn't empty — run in an Expo project or an empty folder.");
216
+ return false;
217
+ }
218
+ if (!scaffoldExpoApp()) return false;
219
+ }
220
+ await installCaloDeps(process.cwd());
221
+ ok("packages + peers installed in this project");
222
+ return true;
223
+ }
224
+
225
+ // ---------------------------------------------------------------- thin project
226
+
227
+ function projectName(projectRoot) {
228
+ return (path.basename(projectRoot).replace(/[^a-z0-9-]/gi, "-").toLowerCase()) || "calo-prototype";
229
+ }
230
+
231
+ function writeThinConfigs(projectRoot) {
232
+ const rt = runtimeDir();
233
+ const name = projectName(projectRoot);
234
+ // package.json copied from the runtime so versions match the symlinked node_modules
235
+ // (kills expo-doctor mismatch noise); just rename it.
236
+ const pjPath = path.join(projectRoot, "package.json");
237
+ if (!fs.existsSync(pjPath)) {
238
+ const pj = JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8"));
239
+ pj.name = name;
240
+ pj.private = true;
241
+ fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2) + "\n");
242
+ }
243
+ // app.json copied + given a unique identity (scheme/slug must be unique to run alongside others)
244
+ const appPath = path.join(projectRoot, "app.json");
245
+ const rtApp = path.join(rt, "app.json");
246
+ if (!fs.existsSync(appPath) && fs.existsSync(rtApp)) {
247
+ const app = JSON.parse(fs.readFileSync(rtApp, "utf8"));
248
+ if (app.expo) { app.expo.name = name; app.expo.slug = name; app.expo.scheme = name.replace(/-/g, ""); }
249
+ fs.writeFileSync(appPath, JSON.stringify(app, null, 2) + "\n");
250
+ }
251
+ // tsconfig + babel (if the runtime has one) copied verbatim
252
+ for (const f of ["tsconfig.json", "babel.config.js"]) {
253
+ const dst = path.join(projectRoot, f);
254
+ const src = path.join(rt, f);
255
+ if (!fs.existsSync(dst) && fs.existsSync(src)) fs.cpSync(src, dst);
256
+ }
257
+ // assets (icon/splash referenced by app.json) so the app boots
258
+ const dstAssets = path.join(projectRoot, "assets");
259
+ const srcAssets = path.join(rt, "assets");
260
+ if (!fs.existsSync(dstAssets) && fs.existsSync(srcAssets)) fs.cpSync(srcAssets, dstAssets, { recursive: true });
261
+ }
262
+
263
+ function writeMetroConfig(projectRoot) {
264
+ const runtimeReal = fs.realpathSync(runtimeDir());
265
+ const content = `process.env.EXPO_NO_METRO_WORKSPACE_ROOT = "1";
266
+ const path = require("node:path");
267
+ const fs = require("node:fs");
268
+ const { getDefaultConfig } = require("expo/metro-config");
269
+
270
+ // This prototype's node_modules is a symlink to the shared Calo runtime. Expo's
271
+ // default config does not watch a node_modules outside the project root, so point
272
+ // Metro at the runtime explicitly. Managed by calo-design — safe to regenerate.
273
+ const projectRoot = __dirname;
274
+ const runtimeRoot = ${JSON.stringify(runtimeReal)};
275
+ const config = getDefaultConfig(projectRoot);
276
+ config.watchFolders = [...(config.watchFolders || []), runtimeRoot];
277
+ config.resolver.nodeModulesPaths = [
278
+ path.resolve(projectRoot, "node_modules"),
279
+ path.resolve(runtimeRoot, "node_modules"),
280
+ ];
281
+ let gorhomWeb;
282
+ try {
283
+ gorhomWeb = require.resolve("@gorhom/bottom-sheet/lib/commonjs/index.js", { paths: [projectRoot, runtimeRoot] });
284
+ } catch (e) {}
285
+ if (gorhomWeb) {
286
+ const base = config.resolver.resolveRequest;
287
+ config.resolver.resolveRequest = (ctx, name, platform) =>
288
+ (base || ctx.resolveRequest)(ctx, platform === "web" && name === "@gorhom/bottom-sheet" ? gorhomWeb : name, platform);
289
+ }
290
+ module.exports = config;
291
+ `;
292
+ fs.writeFileSync(path.join(projectRoot, "metro.config.js"), content);
293
+ }
294
+
295
+ function writeStarterApp(projectRoot) {
296
+ const appDir = path.join(projectRoot, "src", "app");
297
+ // Never clobber existing screens.
298
+ if (fs.existsSync(path.join(appDir, "index.tsx")) || fs.existsSync(path.join(projectRoot, "app", "index.tsx"))) return;
299
+ fs.mkdirSync(appDir, { recursive: true });
300
+ fs.writeFileSync(path.join(appDir, "_layout.tsx"), `import { Stack } from "expo-router";
301
+ import { useFonts } from "expo-font";
302
+ import { caloFonts } from "@calo/design-system";
303
+
304
+ export default function Layout() {
305
+ const [loaded] = useFonts(caloFonts);
306
+ if (!loaded) return null;
307
+ return <Stack screenOptions={{ headerShown: false }} />;
308
+ }
309
+ `);
310
+ fs.writeFileSync(path.join(appDir, "index.tsx"), `import { CaloScreen, CaloText } from "@calo/design-system";
311
+
312
+ export default function Index() {
313
+ return (
314
+ <CaloScreen title="Calo prototype">
315
+ <CaloText role="body">Ask Claude Code to build a Calo screen here.</CaloText>
316
+ </CaloScreen>
317
+ );
318
+ }
319
+ `);
320
+ }
321
+
322
+ function writeGitignore(projectRoot) {
323
+ const gi = path.join(projectRoot, ".gitignore");
324
+ const needed = ["node_modules/", ".expo/", "dist/", "*.log"];
325
+ let cur = "";
326
+ try { cur = fs.readFileSync(gi, "utf8"); } catch {}
327
+ const lines = cur.split(/\r?\n/);
328
+ const add = needed.filter((n) => !lines.includes(n));
329
+ if (add.length) fs.writeFileSync(gi, (cur && !cur.endsWith("\n") ? cur + "\n" : cur) + add.join("\n") + "\n");
330
+ }
331
+
332
+ function linkNodeModules(projectRoot) {
333
+ const target = fs.realpathSync(path.join(runtimeDir(), "node_modules"));
334
+ const link = path.join(projectRoot, "node_modules");
335
+ let st = null;
336
+ try { st = fs.lstatSync(link); } catch {}
337
+ if (st) {
338
+ if (st.isSymbolicLink()) fs.unlinkSync(link); // replace a stale link
339
+ else throw new Error("a real node_modules already exists here");
340
+ }
341
+ // Windows: 'junction' works for dirs without admin/Developer Mode; 'dir' elsewhere.
342
+ fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
343
+ }
344
+
345
+ function linkProject(projectRoot) {
346
+ log(c.b("\n[link] Linking this prototype to the shared runtime"));
347
+ writeThinConfigs(projectRoot);
348
+ writeMetroConfig(projectRoot);
349
+ writeStarterApp(projectRoot);
350
+ writeGitignore(projectRoot);
351
+ linkNodeModules(projectRoot);
352
+ ok(`linked — node_modules -> ${tilde(path.join(runtimeDir(), "node_modules"))}`);
353
+ }
354
+
355
+ function isLinkedProject() {
356
+ try {
357
+ const link = path.join(process.cwd(), "node_modules");
358
+ if (!fs.lstatSync(link).isSymbolicLink()) return false;
359
+ return fs.realpathSync(link).startsWith(fs.realpathSync(runtimeDir()));
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+ function isExistingProject() {
365
+ const nm = path.join(process.cwd(), "node_modules");
366
+ try { if (fs.lstatSync(nm).isDirectory()) return true; } catch {} // a real node_modules (lstat on a symlink isn't a dir)
367
+ return hasPackageJson() && isExpoProject() && !dirIsScaffoldable();
368
+ }
369
+
370
+ // ---------------------------------------------------------------- commands
371
+
372
+ function nextSteps({ linked } = {}) {
373
+ log(c.b("\n✨ Done. In Claude Code — no restart needed:"));
374
+ log(`\n ${c.b("1.")} Load the skill — type:`);
375
+ log(` ${c.b(c.g("/calo-design"))}`);
376
+ log(`\n ${c.b("2.")} Create a prototype — describe the screen, e.g.:`);
377
+ log(` ${c.b(c.g("build a Calo-native meal-swap screen"))}`);
378
+ log(`\n ${c.b("3.")} Preview — run ${c.dim("npx expo start")} (press w for web, or scan the QR in Expo Go).`);
379
+ if (linked) {
380
+ log(c.dim(`\n This prototype shares one install at ${tilde(runtimeDir())} — \`calo-design update\` refreshes it for every prototype.`));
381
+ log(c.dim(" (expo-doctor may flag dependency versions on linked projects; that's expected and harmless.)"));
382
+ }
383
+ log(c.dim(`\n Docs: https://github.com/${SKILL_REPO}`));
384
+ }
385
+
386
+ async function cmdInit() {
387
+ await ensureLoggedIn();
388
+ await installSkill();
389
+ if (has("--skip-packages")) return nextSteps({});
390
+ if (has("--isolated")) { await installHere(); return nextSteps({}); }
391
+
392
+ if (isLinkedProject()) {
393
+ // Re-running in an already-linked prototype: ensure runtime + refresh the link.
394
+ await ensureRuntime({ force: false });
395
+ linkProject(process.cwd());
396
+ return nextSteps({ linked: true });
397
+ }
398
+ if (isExistingProject()) {
399
+ warn("existing project detected — installing the Calo stack here (not linking), to avoid clobbering your node_modules.");
400
+ await installCaloDeps(process.cwd());
401
+ ok("packages + peers installed in this project");
402
+ return nextSteps({});
403
+ }
404
+ if (!dirIsScaffoldable()) {
405
+ warn("this folder isn't empty and isn't an Expo project.");
406
+ log(c.dim(" Run init in an empty folder to use the shared runtime, or `init --isolated` to install here."));
407
+ return;
408
+ }
409
+
410
+ // Fresh prototype folder → shared runtime + symlink, with graceful fallbacks.
411
+ try {
412
+ await ensureRuntime({ force: false });
413
+ } catch (e) {
414
+ warn(`couldn't build the shared runtime (${e.message}); falling back to a per-project install.`);
415
+ await installHere();
416
+ return nextSteps({});
417
+ }
418
+ try {
419
+ linkProject(process.cwd());
420
+ } catch (e) {
421
+ warn(`couldn't link to the shared runtime (${e.message}); falling back to a per-project install.`);
422
+ await installHere();
423
+ return nextSteps({});
424
+ }
425
+ nextSteps({ linked: true });
426
+ }
427
+
428
+ async function cmdUpdate() {
429
+ await ensureLoggedIn();
430
+ await installSkill();
431
+ ok("skill updated to latest");
432
+ if (runtimeExists() || has("--build")) {
433
+ await ensureRuntime({ force: true });
434
+ ok("shared runtime re-pinned — every linked prototype now uses the latest Calo stack");
435
+ } else {
436
+ log(c.dim(" No shared runtime yet — run `calo-design init` in a prototype folder to create one."));
437
+ }
438
+ }
439
+
440
+ function help() {
441
+ log(`${c.b("calo-design")} — one-line setup for Calo design tooling
442
+
443
+ ${c.b("npx @calo-design/cli login")} log in with your Calo email (one-time; no GitHub account needed)
444
+ ${c.b("npx @calo-design/cli init")} skill + a prototype linked to the shared runtime (empty folder, or an existing project)
445
+ ${c.dim("init --isolated")} install the Calo stack into THIS project (no shared runtime)
446
+ ${c.dim("init --skip-packages")} skill only
447
+ ${c.dim("update")} refresh the skill + re-pin the shared runtime (all linked prototypes float to latest)
448
+ ${c.dim("logout")} forget the saved Calo session
449
+ ${c.dim("push")} publish THIS prototype to the Calo Mirror (eas update + registry)
450
+ ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run")}
451
+
452
+ Login is required once before init; the session refreshes automatically.
453
+ Prototypes share one install at ${c.dim(tilde(runtimeDir()))} (override with DESIGNCHEF_HOME).
454
+ `);
455
+ }
456
+
457
+ (async () => {
458
+ try {
459
+ if (cmd === "login") await cmdLogin(args.slice(1));
460
+ else if (cmd === "logout") cmdLogout();
461
+ else if (cmd === "init") await cmdInit();
462
+ else if (cmd === "update") await cmdUpdate();
463
+ else if (cmd === "push") await cmdPush(args.slice(1));
464
+ else help();
465
+ } catch (err) {
466
+ log(`\n\x1b[31m✗\x1b[0m ${err.message}`);
467
+ log(c.dim(" Stuck? Re-run `calo-design login`, then retry. Or check that the Calo broker is reachable."));
468
+ process.exit(1);
469
+ }
470
+ })();
package/bin/login.js ADDED
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `calo-design login` — email-OTP login against the Calo broker, and the helpers
5
+ * that turn a stored session into a short-lived GitHub token at install time.
6
+ *
7
+ * No GitHub account, no PAT, no SSH key. The session lives at
8
+ * ~/.designchef/session.json (chmod 600); the broker holds every real secret.
9
+ */
10
+
11
+ const fs = require("node:fs");
12
+ const os = require("node:os");
13
+ const path = require("node:path");
14
+ const readline = require("node:readline");
15
+
16
+ // Override for local dev with CALO_BROKER_URL=http://localhost:8080
17
+ const BROKER = (process.env.CALO_BROKER_URL || "https://calo-broker.fly.dev").replace(/\/+$/, "");
18
+
19
+ function home() {
20
+ return process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
21
+ }
22
+ function sessionPath() {
23
+ return path.join(home(), "session.json");
24
+ }
25
+ function loadSession() {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(sessionPath(), "utf8"));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ function saveSession(s) {
33
+ fs.mkdirSync(home(), { recursive: true });
34
+ fs.writeFileSync(sessionPath(), JSON.stringify(s, null, 2) + "\n");
35
+ try {
36
+ fs.chmodSync(sessionPath(), 0o600);
37
+ } catch {}
38
+ }
39
+ function clearSession() {
40
+ try {
41
+ fs.rmSync(sessionPath());
42
+ } catch {}
43
+ }
44
+
45
+ async function api(pathname, body, token) {
46
+ let res;
47
+ try {
48
+ res = await fetch(BROKER + pathname, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
51
+ body: JSON.stringify(body || {}),
52
+ });
53
+ } catch (e) {
54
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
55
+ }
56
+ const text = await res.text();
57
+ let json;
58
+ try {
59
+ json = text ? JSON.parse(text) : {};
60
+ } catch {
61
+ json = { raw: text };
62
+ }
63
+ if (!res.ok) throw new Error(json.error || `${pathname} → HTTP ${res.status}`);
64
+ return json;
65
+ }
66
+
67
+ function ask(question) {
68
+ return new Promise((resolve) => {
69
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
70
+ rl.question(question, (a) => {
71
+ rl.close();
72
+ resolve(a.trim());
73
+ });
74
+ });
75
+ }
76
+
77
+ const flag = (args, name) => {
78
+ const hit = args.find((a) => a.startsWith(`--${name}=`));
79
+ return hit ? hit.split("=").slice(1).join("=") : undefined;
80
+ };
81
+ const nowSec = () => Math.floor(Date.now() / 1000);
82
+ const expSec = (s) =>
83
+ typeof s.expiresAt === "number" ? s.expiresAt : Math.floor(new Date(s.expiresAt).getTime() / 1000);
84
+
85
+ async function cmdLogin(args = []) {
86
+ // When driven by an agent (Claude Code) stdin isn't a TTY, so we can't prompt —
87
+ // we send the code and exit telling the agent the exact next command to run.
88
+ const interactive = Boolean(process.stdin.isTTY);
89
+ let email = flag(args, "email");
90
+ if (!email) {
91
+ if (!interactive) throw new Error("Pass --email=you@calo.app — a code will be emailed, then re-run with --code=<code>.");
92
+ email = await ask("Calo email: ");
93
+ }
94
+ let code = flag(args, "code");
95
+ if (!code) {
96
+ // Request a code. (Pass --code to skip the send, e.g. a code you already have.)
97
+ await api("/v1/login/start", { email });
98
+ if (!interactive) {
99
+ console.log(`Sent a 6-digit code to ${email}. Now run: npx @calo-design/cli login --email=${email} --code=<the 6-digit code>`);
100
+ return;
101
+ }
102
+ console.log(`We emailed a 6-digit code to ${email}.`);
103
+ code = await ask("Code: ");
104
+ }
105
+ const r = await api("/v1/login/verify", { email, code });
106
+ saveSession({ session: r.session, refresh: r.refresh, email: r.email, expiresAt: r.expiresAt });
107
+ console.log(`\x1b[32m✓\x1b[0m Logged in as ${r.email}`);
108
+ }
109
+
110
+ function cmdLogout() {
111
+ clearSession();
112
+ console.log("✓ Logged out");
113
+ }
114
+
115
+ // A valid (refreshed if needed) session JWT, or throws telling the user to log in.
116
+ async function ensureSession() {
117
+ const s = loadSession();
118
+ if (!s || !s.session) throw new Error("Not logged in — run `calo-design login`.");
119
+ if (expSec(s) - 60 > nowSec()) return s.session;
120
+ if (!s.refresh) throw new Error("Session expired — run `calo-design login`.");
121
+ try {
122
+ const r = await api("/v1/session/refresh", { refresh: s.refresh });
123
+ saveSession({ ...s, session: r.session, expiresAt: r.expiresAt });
124
+ return r.session;
125
+ } catch {
126
+ throw new Error("Session expired — run `calo-design login`.");
127
+ }
128
+ }
129
+
130
+ // Logs in interactively if there's no usable session yet.
131
+ async function ensureLoggedIn() {
132
+ const s = loadSession();
133
+ if (s && s.session) {
134
+ try {
135
+ await ensureSession();
136
+ return;
137
+ } catch {}
138
+ }
139
+ console.log("First, log in with your Calo email.");
140
+ await cmdLogin([]);
141
+ }
142
+
143
+ // Short-lived GitHub token for private installs (broker-minted).
144
+ async function githubToken() {
145
+ const session = await ensureSession();
146
+ const r = await api("/v1/github-token", {}, session);
147
+ return r.token;
148
+ }
149
+
150
+ module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, BROKER, loadSession };