@calo-design/cli 0.4.1 → 0.4.3

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"));
@@ -424,6 +490,8 @@ async function cmdInit() {
424
490
  log(c.dim(`\n Prototype folder: ${tilde(dir)}`));
425
491
  }
426
492
  const cd = targetName;
493
+ // Slug used for the design-ops feed (The Pass) — the prototype's folder name.
494
+ const slug = targetName || path.basename(process.cwd());
427
495
 
428
496
  // Machine setup runs every time, independent of this folder: the skill is installed
429
497
  // above; ensure the shared runtime so @calo/design-system + @calo/flows live on this
@@ -441,12 +509,14 @@ async function cmdInit() {
441
509
  // Re-running in an already-linked prototype: refresh the link if the runtime is ok.
442
510
  if (runtimeReady) linkProject(process.cwd());
443
511
  else warn("shared runtime unavailable — left the existing link untouched.");
512
+ await reportEvent("init", slug);
444
513
  return nextSteps({ linked: true, cd });
445
514
  }
446
515
  if (isExistingProject()) {
447
516
  warn("existing project detected — installing the Calo stack here (not linking), to avoid clobbering your node_modules.");
448
517
  await installCaloDeps(process.cwd());
449
518
  ok("packages + peers installed in this project");
519
+ await reportEvent("init", slug);
450
520
  return nextSteps({ cd });
451
521
  }
452
522
  if (!dirIsScaffoldable()) {
@@ -462,6 +532,7 @@ async function cmdInit() {
462
532
  }
463
533
 
464
534
  // Fresh prototype folder → link to the shared runtime, with a per-project fallback.
535
+ await reportEvent("init", slug);
465
536
  if (!runtimeReady) { await installHere(); return nextSteps({ cd }); }
466
537
  try {
467
538
  linkProject(process.cwd());
@@ -486,6 +557,49 @@ async function cmdUpdate() {
486
557
  } else {
487
558
  log(c.dim(" No shared runtime yet — run `calo-design init` in a prototype folder to create one."));
488
559
  }
560
+ // Report the refresh to the design-ops feed, tagged with the DS version we floated to.
561
+ let dsVersion = "";
562
+ try {
563
+ dsVersion = require(path.join(runtimeDir(), "node_modules", "@calo", "design-system", "package.json")).version || "";
564
+ } catch { /* runtime not present — report without a version */ }
565
+ await reportEvent("update", dsVersion);
566
+ }
567
+
568
+ // Print the logged-in identity. `--json` for machine callers (The Pass). The JSON is written
569
+ // on its own line (leading newline) so a chatty shell rc can't fuse output onto the JSON line;
570
+ // The Pass parses the last non-empty stdout line.
571
+ async function cmdWhoami() {
572
+ const s = loadSession();
573
+ if (!s || !s.email) {
574
+ if (has("--json")) { process.stdout.write("\n" + JSON.stringify({ email: null }) + "\n"); return; }
575
+ log("Not logged in — run `calo-design login`.");
576
+ process.exit(1);
577
+ }
578
+ if (has("--json")) { process.stdout.write("\n" + JSON.stringify({ email: s.email, expiresAt: s.expiresAt }) + "\n"); return; }
579
+ log(s.email);
580
+ }
581
+
582
+ // The design-ops feed (The Pass reads this via `--json`). Reuses the stored session —
583
+ // no second login. ensureSession refreshes a stale JWT silently using the refresh token.
584
+ async function cmdFeed() {
585
+ const session = await ensureSession();
586
+ const li = args.indexOf("--limit");
587
+ const limit = li >= 0 && /^\d+$/.test(args[li + 1] || "") ? Math.max(1, Math.min(500, parseInt(args[li + 1], 10))) : 100;
588
+ let res;
589
+ try {
590
+ // Bounded so a black-holed broker can't hang the CLI (and, in turn, The Pass shelling out here).
591
+ res = await fetch(`${BROKER}/v1/events?limit=${limit}`, {
592
+ headers: { authorization: `Bearer ${session}` },
593
+ signal: AbortSignal.timeout(15000),
594
+ });
595
+ } catch (e) {
596
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
597
+ }
598
+ const text = await res.text();
599
+ if (!res.ok) throw new Error(`feed failed (${res.status}): ${text.slice(0, 200)}`);
600
+ if (has("--json")) { process.stdout.write("\n" + text + "\n"); return; }
601
+ const data = JSON.parse(text);
602
+ for (const e of data.events) log(`${c.dim(e.ts)} ${e.email} ${c.b(e.action)} ${e.target || ""}`);
489
603
  }
490
604
 
491
605
  function help() {
@@ -513,16 +627,21 @@ function help() {
513
627
 
514
628
  (async () => {
515
629
  try {
630
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") { console.log(require("../package.json").version); return; }
516
631
  if (cmd === "login") await cmdLogin(args.slice(1));
517
632
  else if (cmd === "logout") cmdLogout();
518
633
  else if (cmd === "init") await cmdInit();
519
634
  else if (cmd === "update") await cmdUpdate();
520
635
  else if (cmd === "push") await cmdPush(args.slice(1));
521
636
  else if (cmd === "share") await cmdShare(args.slice(1));
637
+ else if (cmd === "feed") await cmdFeed();
638
+ else if (cmd === "whoami") await cmdWhoami();
522
639
  else help();
523
640
  } 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."));
641
+ // Diagnostics go to stderr so stdout stays pure for machine callers (The Pass parses
642
+ // `feed --json` / `whoami --json` from stdout); a non-zero exit is the failure signal.
643
+ console.error(`\n\x1b[31m✗\x1b[0m ${err.message}`);
644
+ console.error(c.dim(" Stuck? Re-run `calo-design login`, then retry. Or check that the Calo broker is reachable."));
526
645
  process.exit(1);
527
646
  }
528
647
  })();
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "One-line setup for Calo design tooling: logs in with your Calo email and installs the calo-design skill + design-system packages. No GitHub account needed.",
5
5
  "bin": {
6
6
  "calo-design": "bin/cli.js"