@calo-design/cli 0.4.2 → 0.4.4

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/bin/cli.js CHANGED
@@ -17,7 +17,7 @@ const os = require("node:os");
17
17
  const path = require("node:path");
18
18
  const { cmdPush } = require("./mirror-push");
19
19
  const { cmdShare } = require("./share");
20
- const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken } = require("./login");
20
+ const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
21
21
 
22
22
  const ORG = "Calo-Design";
23
23
  const SKILL_REPO = `${ORG}/calo-design`;
@@ -141,11 +141,77 @@ function ensureBabelConfig(dir) {
141
141
  fs.writeFileSync(p, 'module.exports = (api) => {\n api.cache(true);\n return { presets: ["babel-preset-expo"] };\n};\n');
142
142
  }
143
143
 
144
+ // The skill is re-installed fresh on every init, but the runtime used to freeze at
145
+ // first install — fresh guidance driving a stale library (the skill can reference
146
+ // primitives the installed @calo/design-system doesn't have yet). Detect when the
147
+ // installed @calo git deps are behind their repo HEAD so init can re-pin in place.
148
+ function installedCaloShas(rt) {
149
+ // npm records a git dep as "resolved": "git+https://…/<repo>.git#<40-hex-sha>"
150
+ const locks = [path.join(rt, "package-lock.json"), path.join(rt, "node_modules", ".package-lock.json")];
151
+ for (const lp of locks) {
152
+ try {
153
+ const lock = JSON.parse(fs.readFileSync(lp, "utf8"));
154
+ const out = {};
155
+ for (const spec of PKG_SPECS) {
156
+ const repo = spec.match(/\/([^/]+?)(?:\.git)?$/)[1];
157
+ for (const v of Object.values(lock.packages || {})) {
158
+ const m = v && v.resolved && String(v.resolved).match(new RegExp(`${repo}(?:\\.git)?#([0-9a-f]{40})`));
159
+ if (m) { out[repo] = m[1]; break; }
160
+ }
161
+ }
162
+ if (Object.keys(out).length) return out;
163
+ } catch {}
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function remoteHeadSha(repoUrl, env) {
169
+ const r = spawnSync("git", ["ls-remote", repoUrl, "HEAD"], { env, encoding: "utf8" });
170
+ if (r.error || r.status !== 0 || !r.stdout) return null;
171
+ const m = r.stdout.match(/^([0-9a-f]{40})\s/);
172
+ return m ? m[1] : null;
173
+ }
174
+
175
+ function staleCaloDeps(rt, env) {
176
+ const installed = installedCaloShas(rt);
177
+ if (!installed) return []; // can't tell — don't churn a working runtime
178
+ const stale = [];
179
+ for (const spec of PKG_SPECS) {
180
+ const repo = spec.match(/\/([^/]+?)(?:\.git)?$/)[1];
181
+ const head = remoteHeadSha(spec.replace(/^git\+/, ""), env);
182
+ // Unknown local sha with a reachable remote also counts, so old runtimes self-heal.
183
+ if (head && head !== installed[repo]) stale.push(repo);
184
+ }
185
+ return stale;
186
+ }
187
+
144
188
  async function ensureRuntime({ force } = {}) {
145
189
  const rt = runtimeDir();
146
190
  if (!force && runtimeExists()) {
147
191
  ensureBabelConfig(rt); // backfill: runtimes built before this shipped no babel.config.js
148
- ok(`shared runtime ready (${tilde(rt)})`);
192
+ let stale = [];
193
+ let env;
194
+ try {
195
+ env = gitTokenEnv(await githubToken());
196
+ stale = staleCaloDeps(rt, env);
197
+ } catch {} // offline / broker hiccup — keep the existing runtime, don't block init
198
+ if (!stale.length) {
199
+ ok(`shared runtime ready (${tilde(rt)})`);
200
+ return;
201
+ }
202
+ log(c.b(`\n[runtime] ${stale.join(" + ")} behind latest — refreshing the shared runtime`));
203
+ try {
204
+ // Non-destructive: `npm install` of the git specs re-resolves HEAD and updates
205
+ // node_modules in place; a failure aborts cleanly and leaves the runtime working.
206
+ runWithRetry("npm", ["install", ...PKG_SPECS, "--legacy-peer-deps"], 3, { cwd: rt, env });
207
+ let manifest = { pkgSpecs: PKG_SPECS, peers: PEERS };
208
+ try { manifest = { ...JSON.parse(fs.readFileSync(runtimeManifestPath(), "utf8")), ...manifest }; } catch {}
209
+ manifest.refreshedAt = new Date().toISOString();
210
+ fs.writeFileSync(runtimeManifestPath(), JSON.stringify(manifest, null, 2) + "\n");
211
+ ok("shared runtime refreshed — every linked prototype now uses the latest Calo stack");
212
+ } catch (e) {
213
+ warn(`couldn't refresh the runtime (${e.message}) — continuing with the existing install; \`calo-design update\` retries this.`);
214
+ }
149
215
  return;
150
216
  }
151
217
  const scaffolded = fs.existsSync(path.join(rt, "package.json"));
@@ -342,6 +408,44 @@ export default function Index() {
342
408
  `);
343
409
  }
344
410
 
411
+ // The component gallery's routes: a root layout (fonts + providers, no local imports so it's
412
+ // portable) and the @calo/design-system showroom (bundled with the CLI) as the index route.
413
+ // Always (re)written so a `calo-design update` + relaunch also ships the latest showroom.
414
+ function writeGalleryApp(projectRoot) {
415
+ const srcDir = path.join(projectRoot, "src");
416
+ const appDir = path.join(srcDir, "app");
417
+ fs.mkdirSync(appDir, { recursive: true });
418
+ // Silences known non-actionable RNW-on-React-19 dev warnings (nested Pressables, deprecated
419
+ // style props) so the embedded gallery has a clean overlay. Imported first in _layout.
420
+ fs.cpSync(path.join(__dirname, "templates", "gallery-suppress.ts"), path.join(srcDir, "suppress-dev-warnings.ts"));
421
+ fs.writeFileSync(path.join(appDir, "_layout.tsx"), `import "../suppress-dev-warnings";
422
+ import { Stack } from "expo-router";
423
+ import { useFonts } from "expo-font";
424
+ import { CaloBottomSheetProvider, CaloScreen, caloFonts, caloTokens } from "@calo/design-system";
425
+ import { SafeAreaProvider } from "react-native-safe-area-context";
426
+
427
+ export default function Layout() {
428
+ const [loaded] = useFonts(caloFonts);
429
+ return (
430
+ <SafeAreaProvider>
431
+ {loaded ? (
432
+ <CaloBottomSheetProvider>
433
+ <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: caloTokens.color.canvas } }} />
434
+ </CaloBottomSheetProvider>
435
+ ) : (
436
+ <CaloScreen />
437
+ )}
438
+ </SafeAreaProvider>
439
+ );
440
+ }
441
+ `);
442
+ const showroom = fs.readFileSync(path.join(__dirname, "templates", "gallery-index.tsx"), "utf8");
443
+ fs.writeFileSync(path.join(appDir, "index.tsx"), showroom);
444
+ // The showroom require()s a few local images (../assets/... from src/app) — ship them too.
445
+ const assetsSrc = path.join(__dirname, "templates", "gallery-assets");
446
+ if (fs.existsSync(assetsSrc)) fs.cpSync(assetsSrc, path.join(projectRoot, "src", "assets"), { recursive: true });
447
+ }
448
+
345
449
  function writeGitignore(projectRoot) {
346
450
  const gi = path.join(projectRoot, ".gitignore");
347
451
  const needed = ["node_modules/", ".expo/", "dist/", "*.log"];
@@ -424,6 +528,8 @@ async function cmdInit() {
424
528
  log(c.dim(`\n Prototype folder: ${tilde(dir)}`));
425
529
  }
426
530
  const cd = targetName;
531
+ // Slug used for the design-ops feed (The Pass) — the prototype's folder name.
532
+ const slug = targetName || path.basename(process.cwd());
427
533
 
428
534
  // Machine setup runs every time, independent of this folder: the skill is installed
429
535
  // above; ensure the shared runtime so @calo/design-system + @calo/flows live on this
@@ -441,12 +547,14 @@ async function cmdInit() {
441
547
  // Re-running in an already-linked prototype: refresh the link if the runtime is ok.
442
548
  if (runtimeReady) linkProject(process.cwd());
443
549
  else warn("shared runtime unavailable — left the existing link untouched.");
550
+ await reportEvent("init", slug);
444
551
  return nextSteps({ linked: true, cd });
445
552
  }
446
553
  if (isExistingProject()) {
447
554
  warn("existing project detected — installing the Calo stack here (not linking), to avoid clobbering your node_modules.");
448
555
  await installCaloDeps(process.cwd());
449
556
  ok("packages + peers installed in this project");
557
+ await reportEvent("init", slug);
450
558
  return nextSteps({ cd });
451
559
  }
452
560
  if (!dirIsScaffoldable()) {
@@ -462,6 +570,7 @@ async function cmdInit() {
462
570
  }
463
571
 
464
572
  // Fresh prototype folder → link to the shared runtime, with a per-project fallback.
573
+ await reportEvent("init", slug);
465
574
  if (!runtimeReady) { await installHere(); return nextSteps({ cd }); }
466
575
  try {
467
576
  linkProject(process.cwd());
@@ -486,6 +595,80 @@ async function cmdUpdate() {
486
595
  } else {
487
596
  log(c.dim(" No shared runtime yet — run `calo-design init` in a prototype folder to create one."));
488
597
  }
598
+ // Report the refresh to the design-ops feed, tagged with the DS version we floated to.
599
+ let dsVersion = "";
600
+ try {
601
+ dsVersion = require(path.join(runtimeDir(), "node_modules", "@calo", "design-system", "package.json")).version || "";
602
+ } catch { /* runtime not present — report without a version */ }
603
+ await reportEvent("update", dsVersion);
604
+ }
605
+
606
+ // Print the logged-in identity. `--json` for machine callers (The Pass). The JSON is written
607
+ // on its own line (leading newline) so a chatty shell rc can't fuse output onto the JSON line;
608
+ // The Pass parses the last non-empty stdout line.
609
+ async function cmdWhoami() {
610
+ const s = loadSession();
611
+ if (!s || !s.email) {
612
+ if (has("--json")) { process.stdout.write("\n" + JSON.stringify({ email: null }) + "\n"); return; }
613
+ log("Not logged in — run `calo-design login`.");
614
+ process.exit(1);
615
+ }
616
+ if (has("--json")) { process.stdout.write("\n" + JSON.stringify({ email: s.email, expiresAt: s.expiresAt }) + "\n"); return; }
617
+ log(s.email);
618
+ }
619
+
620
+ // The design-ops feed (The Pass reads this via `--json`). Reuses the stored session —
621
+ // no second login. ensureSession refreshes a stale JWT silently using the refresh token.
622
+ async function cmdFeed() {
623
+ const session = await ensureSession();
624
+ const li = args.indexOf("--limit");
625
+ const limit = li >= 0 && /^\d+$/.test(args[li + 1] || "") ? Math.max(1, Math.min(500, parseInt(args[li + 1], 10))) : 100;
626
+ let res;
627
+ try {
628
+ // Bounded so a black-holed broker can't hang the CLI (and, in turn, The Pass shelling out here).
629
+ res = await fetch(`${BROKER}/v1/events?limit=${limit}`, {
630
+ headers: { authorization: `Bearer ${session}` },
631
+ signal: AbortSignal.timeout(15000),
632
+ });
633
+ } catch (e) {
634
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
635
+ }
636
+ const text = await res.text();
637
+ if (!res.ok) throw new Error(`feed failed (${res.status}): ${text.slice(0, 200)}`);
638
+ if (has("--json")) { process.stdout.write("\n" + text + "\n"); return; }
639
+ const data = JSON.parse(text);
640
+ for (const e of data.events) log(`${c.dim(e.ts)} ${e.email} ${c.b(e.action)} ${e.target || ""}`);
641
+ }
642
+
643
+ // Serve the component gallery (the @calo/design-system showroom) locally, rendered LIVE from
644
+ // the shared runtime — so `calo-design update` keeps it current (no rebuild/redeploy). Design
645
+ // Kitchen auto-starts this; it also works standalone in a terminal. Runs Metro in the
646
+ // foreground so whoever launched it owns the process lifecycle.
647
+ async function cmdGallery() {
648
+ if (!runtimeExists()) {
649
+ throw new Error("The Calo runtime isn't set up on this machine yet — run `calo-design init` first, then retry.");
650
+ }
651
+ const dir = path.join(designchefHome(), "gallery");
652
+ fs.mkdirSync(dir, { recursive: true });
653
+ // Materialize / refresh a thin project linked to the shared runtime (same machinery as
654
+ // a linked prototype), then swap in the showroom routes.
655
+ writeThinConfigs(dir);
656
+ ensureBabelConfig(dir);
657
+ writeMetroConfig(dir);
658
+ writeGitignore(dir);
659
+ linkNodeModules(dir);
660
+ writeGalleryApp(dir);
661
+
662
+ let port = "8091";
663
+ const pj = args.find((a) => a.startsWith("--port="));
664
+ if (pj) port = pj.slice(7);
665
+ else { const pi = args.indexOf("--port"); if (pi >= 0) port = args[pi + 1] || port; }
666
+ if (!/^\d+$/.test(port)) port = "8091";
667
+
668
+ log(c.b(`\n[gallery] Serving the Calo component gallery on http://localhost:${port}`));
669
+ log(c.dim(` Live from the shared runtime (${tilde(runtimeDir())}) — always the installed @calo/design-system.`));
670
+ log(c.dim(" First launch compiles the design system (~30–60s). Ctrl-C to stop."));
671
+ run("npx", ["expo", "start", "--web", "--port", port], { cwd: dir });
489
672
  }
490
673
 
491
674
  function help() {
@@ -503,6 +686,8 @@ function help() {
503
686
  ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run --direct")}
504
687
  ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
505
688
  ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
689
+ ${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
690
+ ${c.dim(" gallery --port 8091")}
506
691
 
507
692
  Login is required once before init; the session refreshes automatically.
508
693
  init always installs the skill and ensures the shared ds/flows runtime on your machine;
@@ -513,16 +698,22 @@ function help() {
513
698
 
514
699
  (async () => {
515
700
  try {
701
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") { console.log(require("../package.json").version); return; }
516
702
  if (cmd === "login") await cmdLogin(args.slice(1));
517
703
  else if (cmd === "logout") cmdLogout();
518
704
  else if (cmd === "init") await cmdInit();
519
705
  else if (cmd === "update") await cmdUpdate();
520
706
  else if (cmd === "push") await cmdPush(args.slice(1));
521
707
  else if (cmd === "share") await cmdShare(args.slice(1));
708
+ else if (cmd === "feed") await cmdFeed();
709
+ else if (cmd === "whoami") await cmdWhoami();
710
+ else if (cmd === "gallery") await cmdGallery();
522
711
  else help();
523
712
  } catch (err) {
524
- log(`\n\x1b[31m✗\x1b[0m ${err.message}`);
525
- log(c.dim(" Stuck? Re-run `calo-design login`, then retry. Or check that the Calo broker is reachable."));
713
+ // Diagnostics go to stderr so stdout stays pure for machine callers (The Pass parses
714
+ // `feed --json` / `whoami --json` from stdout); a non-zero exit is the failure signal.
715
+ console.error(`\n\x1b[31m✗\x1b[0m ${err.message}`);
716
+ console.error(c.dim(" Stuck? Re-run `calo-design login`, then retry. Or check that the Calo broker is reachable."));
526
717
  process.exit(1);
527
718
  }
528
719
  })();
package/bin/login.js CHANGED
@@ -42,13 +42,16 @@ function clearSession() {
42
42
  } catch {}
43
43
  }
44
44
 
45
- async function api(pathname, body, token) {
45
+ async function api(pathname, body, token, { timeoutMs } = {}) {
46
46
  let res;
47
47
  try {
48
48
  res = await fetch(BROKER + pathname, {
49
49
  method: "POST",
50
50
  headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
51
51
  body: JSON.stringify(body || {}),
52
+ // Opt-in timeout. Existing callers keep their (unbounded) behavior; telemetry passes a
53
+ // tight bound so a black-holed broker (connect-but-silent) can never hang the command.
54
+ ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
52
55
  });
53
56
  } catch (e) {
54
57
  throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
@@ -190,4 +193,17 @@ async function publishToMirror(payload) {
190
193
  return api("/v1/publish", payload, session);
191
194
  }
192
195
 
193
- module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishWeb, publishToMirror, BROKER, loadSession };
196
+ // Best-effort design-ops telemetry: report an init/update to the broker feed (The Pass).
197
+ // Never throws and never blocks the command on failure — telemetry is not the job. Uses the
198
+ // existing session if it's still valid; won't trigger a refresh just to report an event.
199
+ async function reportEvent(action, target) {
200
+ try {
201
+ const s = loadSession();
202
+ if (!s || !s.session || expSec(s) - 60 <= nowSec()) return; // not logged in / expired — skip
203
+ await api("/v1/events", { action, target: target || "" }, s.session, { timeoutMs: 3000 });
204
+ } catch {
205
+ /* swallow — telemetry must never break a command */
206
+ }
207
+ }
208
+
209
+ module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishWeb, publishToMirror, reportEvent, BROKER, loadSession };
package/bin/share.js CHANGED
@@ -144,10 +144,19 @@ async function cmdShare(args = []) {
144
144
  const sizeMb = (fs.statSync(tgz).size / 1e6).toFixed(1);
145
145
  log(c.b(`\n[upload] ${sizeMb} MB → broker → wrangler pages deploy`));
146
146
  const r = await publishWeb({ slug, project, tarball: fs.readFileSync(tgz) });
147
+ const url = r.url || aliasUrl(slug, project);
147
148
  log("");
148
- ok(`Live: ${c.g(r.url || aliasUrl(slug, project))}`);
149
+ // The broker verifies the URL actually serves (verified:false => deployed but the link
150
+ // didn't answer). Don't print a confident "Live" for a link the broker couldn't reach.
151
+ if (r.verified === false) {
152
+ warn(`Deployed, but ${url} did not respond yet.`);
153
+ log(c.dim(" If it's still blank in ~30s, the broker likely deployed to a different"));
154
+ log(c.dim(" Cloudflare account/project than this URL implies — check broker CLOUDFLARE_* config."));
155
+ } else {
156
+ ok(`Live: ${c.g(url)}`);
157
+ log(c.dim(` Re-run \`calo-design share\` to update “${slug}” in place. Share the link above.`));
158
+ }
149
159
  if (r.version) log(c.dim(` this version: ${r.version}`));
150
- log(c.dim(` Re-run \`calo-design share\` to update “${slug}” in place. Share the link above.`));
151
160
  } finally {
152
161
  try { fs.rmSync(tgz); } catch {}
153
162
  }