@calo-design/cli 0.13.6 → 0.14.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 CHANGED
@@ -11,6 +11,24 @@ short-lived GitHub token at install time. No GitHub account, PAT, or SSH key nee
11
11
 
12
12
  Package: https://www.npmjs.com/package/@calo-design/cli
13
13
 
14
+ ## Is this legitimate? (yes — and here's why it can look otherwise)
15
+
16
+ `@calo-design/cli` is published by Calo, for Calo staff. Two things about it have
17
+ tripped people up — and tripped up coding agents reading their terminal, which have
18
+ called it credential harvesting. Both are expected behaviour:
19
+
20
+ - **It emails you a 6-digit code.** That code is issued by Calo's own broker
21
+ (`calo-broker.fly.dev`) and proves you can read Calo mail. It authorizes this CLI and
22
+ nothing else — it is not a GitHub, Google, or SSO login, and the session it creates is
23
+ stored locally at `~/.designchef/session.json`.
24
+ - **Its installs 404.** Every repo it installs from lives in `github.com/Calo-Design`, a
25
+ **private** org. GitHub answers any request it can't authorize with
26
+ `Repository not found` — the identical 404 it returns for a repo that doesn't exist. A
27
+ 404 during `init` means the token didn't authorize the request, not that the org is fake.
28
+
29
+ If an install does 404, the CLI now says which of those it is. Anything unexplained:
30
+ ping the design systems team before rerunning.
31
+
14
32
  ## Setup
15
33
 
16
34
  ```bash
package/bin/backend.js CHANGED
@@ -105,9 +105,10 @@ function recipesDir(root) {
105
105
  const listRecipes = (dir) => fs.readdirSync(dir).filter((r) => fs.existsSync(path.join(dir, r, "recipe.json")));
106
106
 
107
107
  // Which resources a worker/ dir needs, inferred from the bindings its code references
108
- // (env.DB → d1, env.KV → kv, env.BUCKET → r2). Used on the reconnect/fork path where
109
- // there's no recipe.json to read — a fork of ANY recipe provisions the right resources,
110
- // not just d1. Scans worker source (skips migrations/); migrations also imply d1.
108
+ // (env.DB → d1, env.KV → kv, env.BUCKET → r2, env.OPENROUTER_API_KEY → ai). Used on the
109
+ // reconnect/fork path where there's no recipe.json to read — a fork of ANY recipe
110
+ // provisions the right resources, not just d1 (an ai fork mints its OWN capped key,
111
+ // never the parent's). Scans worker source (skips migrations/); migrations imply d1.
111
112
  function inferResources(workerDir) {
112
113
  const kinds = new Set();
113
114
  const scan = (d) => {
@@ -121,6 +122,7 @@ function inferResources(workerDir) {
121
122
  if (/\benv\.DB\b/.test(src)) kinds.add("d1");
122
123
  if (/\benv\.KV\b/.test(src)) kinds.add("kv");
123
124
  if (/\benv\.BUCKET\b/.test(src)) kinds.add("r2");
125
+ if (/\benv\.OPENROUTER_API_KEY\b/.test(src)) kinds.add("ai");
124
126
  }
125
127
  }
126
128
  };
@@ -215,10 +217,18 @@ async function cmdInit(args) {
215
217
 
216
218
  async function cmdAdd(args) {
217
219
  const kind = (args[0] || "").toLowerCase();
218
- if (!["d1", "kv", "r2"].includes(kind)) throw new Error("usage: calo-design backend add <d1|kv|r2>");
220
+ if (!["d1", "kv", "r2", "ai"].includes(kind)) throw new Error("usage: calo-design backend add <d1|kv|r2|ai>");
219
221
  const root = process.cwd();
220
222
  const slug = resolveSlug(args.slice(1), root);
221
223
  const r = await call("POST", "/v1/backend/provision", { body: { slug, kind } });
224
+ if (kind === "ai") {
225
+ // The prototype's OWN capped OpenRouter key — minted by the broker, enforced by
226
+ // OpenRouter (monthly reset). Nothing to sign up for, nothing to paste.
227
+ ok(`ai ready — this prototype has its own $${(r.ai && r.ai.limitUsd) || 50}/month AI budget`);
228
+ log(c.dim(" key lands as a worker secret on the next `calo-design backend deploy`"));
229
+ log(c.dim(" spend is viewer-time: anyone with a share link can generate against the cap"));
230
+ return;
231
+ }
222
232
  ok(`${kind} ready ${c.dim(`(${(r.resources.find((x) => x.kind === kind) || {}).name || ""})`)}`);
223
233
  log(c.dim(" binding lands on the next `calo-design backend deploy`"));
224
234
  }
@@ -283,7 +293,18 @@ async function cmdStatus(args) {
283
293
  log(` worker ${s.workerUrl}${s.deployHash ? "" : c.dim(" (never deployed)")}`);
284
294
  if (s.deployHash) log(` deploy ${s.deployHash.slice(0, 12)} ${c.dim(new Date(s.deployedAt).toISOString())}`);
285
295
  for (const r of s.resources) log(` ${r.kind.padEnd(8)} ${r.name}`);
286
- if (!s.resources.length) log(c.dim(" no resources — `calo-design backend add <d1|kv|r2>`"));
296
+ if (s.ai) {
297
+ // AI budget line: cap from the registry, month-to-date spend read live from
298
+ // OpenRouter (best-effort — the budget line still prints if the rollup fails).
299
+ let spend = "";
300
+ try {
301
+ const u = JSON.parse(await call("GET", "/v1/backend/ai-usage", { raw: true }));
302
+ const mine = (u.keys || []).find((k) => k.slug === s.slug);
303
+ if (mine && Number.isFinite(mine.usageMonthUsd)) spend = `$${mine.usageMonthUsd.toFixed(2)} used of `;
304
+ } catch {}
305
+ log(` ai ${spend}$${s.ai.limitUsd}/month AI budget${s.ai.disabled ? c.dim(" (disabled — `backend add ai` re-mints)") : ""}`);
306
+ }
307
+ if (!s.resources.length && !s.ai) log(c.dim(" no resources — `calo-design backend add <d1|kv|r2|ai>`"));
287
308
  }
288
309
 
289
310
  const parseSince = (v) => {
@@ -425,6 +446,33 @@ async function cmdAll(args) {
425
446
  }
426
447
  }
427
448
 
449
+ // AI spend across YOUR AI-enabled prototypes (broker reads each key's usage live
450
+ // from OpenRouter). Feeds Design Kitchen's Usage tab (`--json`) and the terminal.
451
+ async function cmdAiUsage(args) {
452
+ const text = await call("GET", "/v1/backend/ai-usage", { raw: true });
453
+ if (has(args, "json")) return emitJson(text);
454
+ const u = JSON.parse(text);
455
+ if (!u.keys.length) return log(c.dim("no AI-enabled prototypes — `calo-design backend add ai` gives one a budget"));
456
+ log(c.b(`AI budgets · ${u.totals.activeKeys}/${u.totals.maxKeys} slots used`));
457
+ for (const k of u.keys) {
458
+ const spend = Number.isFinite(k.usageMonthUsd) ? `$${k.usageMonthUsd.toFixed(2)}` : c.dim("(usage unavailable)");
459
+ log(` ${k.slug.padEnd(28)} ${spend} of $${k.limitUsd}/month${k.disabled ? c.dim(" (disabled)") : ""}`);
460
+ }
461
+ log(c.dim(` total this month: $${u.totals.usageMonthUsd.toFixed(2)} of $${u.totals.limitUsd} across active budgets`));
462
+ }
463
+
464
+ // Kill switch for the prototype's AI key (leak, abuse, or freeing a quota slot).
465
+ // Disables upstream at OpenRouter — spend stops immediately, even though the secret
466
+ // is still on the worker. Re-mint = `backend add ai`, then `backend deploy`.
467
+ async function cmdAiDisable(args) {
468
+ const root = process.cwd();
469
+ const slug = resolveSlug(args, root);
470
+ const r = await call("POST", "/v1/backend/ai-disable", { body: { slug } });
471
+ if (r.alreadyDisabled) return ok("AI key already disabled");
472
+ ok("AI key disabled — spend stopped at OpenRouter");
473
+ log(c.dim(" re-mint with `calo-design backend add ai`, then `backend deploy`"));
474
+ }
475
+
428
476
  async function cmdBackend(args) {
429
477
  const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
430
478
  const rest = args.slice(1);
@@ -438,8 +486,10 @@ async function cmdBackend(args) {
438
486
  if (sub === "check") return cmdCheck(rest);
439
487
  if (sub === "secrets") return cmdSecrets(rest);
440
488
  if (sub === "rotate-key") return cmdRotateKey(rest);
489
+ if (sub === "ai-disable") return cmdAiDisable(rest);
490
+ if (sub === "ai-usage") return cmdAiUsage(rest);
441
491
  throw new Error(
442
- "unknown backend command — one of: init, add <d1|kv|r2>, deploy, status, logs, sql, check, secrets set, rotate-key, all"
492
+ "unknown backend command — one of: init, add <d1|kv|r2|ai>, deploy, status, logs, sql, check, secrets set, rotate-key, ai-usage, ai-disable, all"
443
493
  );
444
494
  }
445
495
 
@@ -42,9 +42,20 @@ const REF_RE = /^([a-z0-9][a-z0-9-]{0,63})\/([a-z0-9][a-z0-9-]{0,63})(?:@(\d{1,6
42
42
  // backend (a fork must provision its own — the worker/ source dir stays IN).
43
43
  // EXCLUDE_NAMES (basename match at any depth) must stay in lockstep with the tar
44
44
  // --exclude patterns below: the canonical hash walks exactly the set tar archives.
45
- const EXCLUDE_NAMES = new Set([".git", "node_modules", ".expo", ".expo-shared", "dist", "build", "web-build", ".DS_Store", MARKER, "backend.json"]);
45
+ // `ios`/`android` are generated by `expo prebuild`/`run:ios` and are HUGE (a single
46
+ // prebuilt ios/ with Pods is >1 GB, ~425 MB gzipped). They used to be missing here
47
+ // while mirror-push's STAGE_SKIP_DIRS already skipped them, so a prototype that had
48
+ // ever been run natively produced a snapshot far over the broker's 15 MB cap — the
49
+ // POST died mid-upload and surfaced as "can't reach the Calo broker", one easily
50
+ // missed line after a successful push. Keep this list in lockstep with
51
+ // STAGE_SKIP_DIRS in mirror-push.js.
52
+ const EXCLUDE_NAMES = new Set([".git", "node_modules", ".expo", ".expo-shared", "dist", "build", "web-build", "ios", "android", ".tamagui", ".vscode", ".idea", ".DS_Store", MARKER, "backend.json"]);
46
53
  const EXCLUDES = [...EXCLUDE_NAMES, ".env*"];
47
54
  const isExcluded = (name) => EXCLUDE_NAMES.has(name) || name.startsWith(".env");
55
+ // Mirrors MAX_SNAPSHOT_BYTES in the broker (src/checkpoints.js). Checked client-side
56
+ // too: over the cap the server closes the connection mid-body, so the client never
57
+ // sees the 413 that would have explained itself.
58
+ const MAX_SNAPSHOT_BYTES = 15e6;
48
59
 
49
60
  // Keep byte-identical with the broker's handleOf (src/checkpoints.js) — it's only used
50
61
  // here to *predict* fork-vs-continue; the broker's derivation is authoritative.
@@ -128,6 +139,36 @@ function canonicalHash(root) {
128
139
  return h.digest("hex");
129
140
  }
130
141
 
142
+ // The top-level folders carrying the most bytes, for the over-the-limit message —
143
+ // "which folder is this?" is the only question that matters at that moment.
144
+ function biggestEntries(root, take = 3) {
145
+ const sizeOf = (p) => {
146
+ let n = 0;
147
+ const rec = (dir) => {
148
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
149
+ if (isExcluded(e.name) || e.isSymbolicLink()) continue;
150
+ const child = path.join(dir, e.name);
151
+ if (e.isDirectory()) rec(child);
152
+ else if (e.isFile()) n += fs.statSync(child).size;
153
+ }
154
+ };
155
+ try {
156
+ if (fs.statSync(p).isDirectory()) rec(p);
157
+ else n = fs.statSync(p).size;
158
+ } catch {
159
+ /* vanished mid-walk — it just doesn't count toward the total */
160
+ }
161
+ return n;
162
+ };
163
+ return fs
164
+ .readdirSync(root, { withFileTypes: true })
165
+ .filter((e) => !isExcluded(e.name) && !e.isSymbolicLink())
166
+ .map((e) => ({ name: e.name, bytes: sizeOf(path.join(root, e.name)) }))
167
+ .sort((a, b) => b.bytes - a.bytes)
168
+ .slice(0, take)
169
+ .map((e) => `${e.name} (${(e.bytes / 1e6).toFixed(1)} MB)`);
170
+ }
171
+
131
172
  // tar the prototype source with the exclusion list; returns { work, tarPath, hash }
132
173
  // in a tmpdir the caller must clean up.
133
174
  function makeSnapshot(root) {
@@ -164,7 +205,14 @@ async function autoCheckpoint({ root, note, publishedUrl, artifact }) {
164
205
  try {
165
206
  await saveFlow({ root, note, publishedUrl, artifact, force: true, quiet: true });
166
207
  } catch (e) {
167
- elog(`${c.y("!")} deploy succeeded, but the source checkpoint didn't: ${e.message}`);
208
+ // Loud on purpose: the deploy is live but UNRECOVERABLE no `calo-design open`,
209
+ // no teammate handoff, no way to get back to the exact source behind it. The old
210
+ // one-liner scrolled past under the QR code and we only noticed weeks later, when
211
+ // a crashing prototype had no source to inspect.
212
+ elog(`\n${c.y("!")} DEPLOY IS LIVE, BUT ITS SOURCE WAS NOT CHECKPOINTED.`);
213
+ elog(c.dim(` Nobody can \`calo-design open\` this version or hand it to a teammate.`));
214
+ elog(` ${e.message}`);
215
+ elog(c.dim(` Fix the cause, then run \`calo-design save\` — the deploy itself is fine.\n`));
168
216
  }
169
217
  }
170
218
 
@@ -198,6 +246,14 @@ async function saveFlow({ root, slugOverride, note = "", force = false, json = f
198
246
  return;
199
247
  }
200
248
  const tgz = zlib.gzipSync(fs.readFileSync(snap.tarPath));
249
+ if (tgz.length > MAX_SNAPSHOT_BYTES) {
250
+ throw new Error(
251
+ `this prototype's source is ${(tgz.length / 1e6).toFixed(0)} MB compressed — over the ${MAX_SNAPSHOT_BYTES / 1e6} MB checkpoint limit.\n` +
252
+ ` Biggest folders: ${biggestEntries(root).join(", ")}\n` +
253
+ ` Checkpoints hold SOURCE only. Move bulk images to the CDN with \`calo-design art push\`,\n` +
254
+ ` or delete generated folders — node_modules, .git, ios, android and dist are already skipped.`
255
+ );
256
+ }
201
257
 
202
258
  let res;
203
259
  try {
@@ -357,4 +413,4 @@ async function cmdProjects(args = []) {
357
413
  log("");
358
414
  }
359
415
 
360
- module.exports = { cmdSave, cmdOpen, cmdProjects, autoCheckpoint, _handleOf: handleOf, _makeSnapshot: makeSnapshot, _canonicalHash: canonicalHash, _EXCLUDES: EXCLUDES };
416
+ module.exports = { cmdSave, _biggestEntries: biggestEntries, _maxSnapshotBytes: MAX_SNAPSHOT_BYTES, cmdOpen, cmdProjects, autoCheckpoint, _handleOf: handleOf, _makeSnapshot: makeSnapshot, _canonicalHash: canonicalHash, _EXCLUDES: EXCLUDES };
package/bin/cli.js CHANGED
@@ -96,28 +96,125 @@ function runWithRetry(bin, argv, tries = 3, opts = {}) {
96
96
  }
97
97
  throw new Error(`${bin} ${argv.join(" ")} failed after ${tries} attempts`);
98
98
  }
99
- // Build an env that makes git authenticate github.com with a broker-minted token,
100
- // for the spawned subprocess ONLY. Uses git's GIT_CONFIG_* env override so nothing
101
- // is written to ~/.gitconfig and the token is never persisted to disk.
99
+ // Every Calo repo the CLI installs from is PRIVATE. GitHub answers a request it
100
+ // can't authorize with "Repository not found" byte-for-byte the same 404 it gives
101
+ // for a repo that never existed. When our token fails to reach git, that 404 is all
102
+ // the user (or the coding agent reading the terminal) has to go on, and it reads as
103
+ // "this org is fake / this package is phishing". So: authenticate two independent
104
+ // ways, and probe before cloning so a failure gets named instead of guessed at.
105
+ //
106
+ // 1. GIT_ASKPASS — git asks a temp script for the password on any URL carrying the
107
+ // `x-access-token@` username. Works on every git in the wild, keeps the token
108
+ // off the argv (invisible to `ps`) and out of lockfiles, and is immune to a
109
+ // user's own url.*.insteadOf rules (those match bare https://github.com/ URLs).
110
+ // 2. url.*.insteadOf via GIT_CONFIG_* — covers URLs we don't control. Honoured by
111
+ // git >= 2.31 only, and a user's own insteadOf can shadow it. Belt and braces.
112
+ //
113
+ // Both live in the spawned subprocess's env only: nothing is written to ~/.gitconfig
114
+ // and the token never touches disk.
115
+ const AUTH_USER = "x-access-token";
116
+
117
+ // Add the username that makes git call GIT_ASKPASS. The token itself stays out of
118
+ // the URL, so it can't leak into argv, npm lockfiles, or git's own error output.
119
+ function authUrl(url) {
120
+ return url.replace("https://github.com/", `https://${AUTH_USER}@github.com/`);
121
+ }
122
+
123
+ let askpassDir = null;
124
+ function askpassScript() {
125
+ if (!askpassDir) {
126
+ askpassDir = fs.mkdtempSync(path.join(os.tmpdir(), "calo-git-"));
127
+ process.on("exit", () => { try { fs.rmSync(askpassDir, { recursive: true, force: true }); } catch {} });
128
+ }
129
+ const win = process.platform === "win32";
130
+ const p = path.join(askpassDir, win ? "askpass.bat" : "askpass.sh");
131
+ if (!fs.existsSync(p)) {
132
+ fs.writeFileSync(p, win ? "@echo off\r\necho %CALO_GIT_TOKEN%\r\n" : '#!/bin/sh\nprintf "%s" "$CALO_GIT_TOKEN"\n');
133
+ try { fs.chmodSync(p, 0o700); } catch {}
134
+ }
135
+ return p;
136
+ }
137
+
102
138
  function gitTokenEnv(token, base = process.env) {
103
139
  // Append at the next free GIT_CONFIG index so we don't clobber any the caller set.
104
140
  const n = parseInt(base.GIT_CONFIG_COUNT || "0", 10) || 0;
105
141
  return {
106
142
  ...base,
107
- GIT_CONFIG_COUNT: String(n + 1),
108
- [`GIT_CONFIG_KEY_${n}`]: `url.https://x-access-token:${token}@github.com/.insteadOf`,
143
+ // (1) askpass — what the URLs we build actually authenticate with.
144
+ GIT_ASKPASS: askpassScript(),
145
+ CALO_GIT_TOKEN: token,
146
+ // (2) insteadOf for bare github.com URLs, plus an empty credential.helper so a
147
+ // stale github.com entry in the user's keychain can't answer ahead of askpass.
148
+ GIT_CONFIG_COUNT: String(n + 2),
149
+ [`GIT_CONFIG_KEY_${n}`]: `url.https://${AUTH_USER}:${token}@github.com/.insteadOf`,
109
150
  [`GIT_CONFIG_VALUE_${n}`]: "https://github.com/",
151
+ [`GIT_CONFIG_KEY_${n + 1}`]: "credential.helper",
152
+ [`GIT_CONFIG_VALUE_${n + 1}`]: "",
110
153
  GIT_TERMINAL_PROMPT: "0"
111
154
  };
112
155
  }
113
156
 
157
+ function gitVersion() {
158
+ const r = spawnSync("git", ["--version"], { encoding: "utf8" });
159
+ const m = r.stdout && r.stdout.match(/(\d+)\.(\d+)/);
160
+ return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
161
+ }
162
+
163
+ // Quiet reachability check against a repo we're about to install from, so the failure
164
+ // lands somewhere we can explain it rather than as a bare 404 in the middle of a clone.
165
+ function probeRepo(url, env) {
166
+ const [b, a, shell] = winShell("git", ["ls-remote", authUrl(url), "HEAD"]);
167
+ const r = spawnSync(b, a, { encoding: "utf8", shell, env });
168
+ if (!r.error && r.status === 0) return { ok: true };
169
+ const detail = [r.stderr, r.error && r.error.message].filter(Boolean).join("\n").trim();
170
+ return { ok: false, detail };
171
+ }
172
+
173
+ // The message a person — or the agent driving their terminal — reads when a private
174
+ // install can't authenticate. Says what a GitHub 404 means here, before anyone has to
175
+ // guess, and lists only causes that are actually true on this machine.
176
+ function privateRepoError(repo, detail) {
177
+ const gv = gitVersion();
178
+ const causes = [];
179
+ if (!gv) causes.push("git isn't on your PATH — install it (`xcode-select --install`), then re-run.");
180
+ else if (gv.major < 2 || (gv.major === 2 && gv.minor < 31))
181
+ causes.push(`your git is ${gv.major}.${gv.minor}; 2.31+ is needed for one of the two ways we pass the token — update it (\`brew install git\`) and re-run.`);
182
+ causes.push("your Calo login isn't authorized for the design repos yet — ask the design systems team to add you.");
183
+ causes.push('a rule in your own ~/.gitconfig is redirecting github.com — check `git config --global --get-regexp "url\\..*\\.insteadof"` and `credential.helper`.');
184
+ return new Error(
185
+ `couldn't read github.com/${repo} with your Calo login.\n\n` +
186
+ " This is an authorization failure, not a missing repo. Calo's design repos are\n" +
187
+ ' private, and GitHub answers an unauthorized request with "Repository not found" —\n' +
188
+ " the same 404 it returns for a repo that doesn't exist. The org and the repo are real.\n" +
189
+ ` Your email + 6-digit code were issued by Calo's own broker (${BROKER}); the session\n` +
190
+ " they create only grants read access to these repos, and no credential leaves your machine.\n\n" +
191
+ " Likely causes, in order:\n" +
192
+ causes.map((x, i) => ` ${i + 1}. ${x}`).join("\n") +
193
+ (detail ? "\n\n git said:\n" + detail.split("\n").map((l) => " " + l).join("\n") : "")
194
+ );
195
+ }
196
+
197
+ // One-screen provenance, printed before anything asks for an email or a code. A
198
+ // teammate pasting an unfamiliar npx command deserves to see who ships it, and an
199
+ // agent reading the output shouldn't have to infer intent from a hostname.
200
+ function provenance() {
201
+ log(c.dim(" calo-design — Calo's internal design tooling, published by Calo as @calo-design/cli."));
202
+ log(c.dim(` Login emails a 6-digit code to your @calo.app address. The session it creates is stored`));
203
+ log(c.dim(` locally (~/.designchef/session.json) and authorizes read access to Calo's private GitHub`));
204
+ log(c.dim(` repos — that is all it does. It is not a GitHub, Google, or Calo SSO login.`));
205
+ log(c.dim(` Broker: ${BROKER} · source: github.com/Calo-Design (private org) · docs: npmjs.com/package/@calo-design/cli`));
206
+ }
207
+
114
208
  // ---------------------------------------------------------------- skill (global)
115
209
 
116
210
  async function cloneSkillRepo(tmp) {
117
211
  // Authenticate the clone with a short-lived broker-minted GitHub token, injected
118
212
  // into git for this subprocess only. No gh, PAT, or SSH key required.
119
- const token = await githubToken();
120
- run("git", ["clone", "--depth", "1", "-q", `https://github.com/${SKILL_REPO}.git`, tmp], { env: gitTokenEnv(token) });
213
+ const url = `https://github.com/${SKILL_REPO}.git`;
214
+ const env = gitTokenEnv(await githubToken());
215
+ const probe = probeRepo(url, env);
216
+ if (!probe.ok) throw privateRepoError(SKILL_REPO, probe.detail);
217
+ run("git", ["clone", "--depth", "1", "-q", authUrl(url), tmp], { env });
121
218
  }
122
219
 
123
220
  async function installSkill() {
@@ -205,7 +302,7 @@ function staleCaloDeps(rt, env) {
205
302
  const stale = [];
206
303
  for (const spec of PKG_SPECS) {
207
304
  const repo = spec.match(/\/([^/]+?)(?:\.git)?$/)[1];
208
- const head = remoteHeadSha(spec.replace(/^git\+/, ""), env);
305
+ const head = remoteHeadSha(authUrl(spec.replace(/^git\+/, "")), env);
209
306
  // Unknown local sha with a reachable remote also counts, so old runtimes self-heal.
210
307
  if (head && head !== installed[repo]) stale.push(repo);
211
308
  }
@@ -243,7 +340,7 @@ async function ensureRuntime({ force } = {}) {
243
340
  try {
244
341
  // Non-destructive: `npm install` of the git specs re-resolves HEAD and updates
245
342
  // node_modules in place; a failure aborts cleanly and leaves the runtime working.
246
- runWithRetry("npm", ["install", ...PKG_SPECS, "--legacy-peer-deps"], 3, { cwd: rt, env });
343
+ runWithRetry("npm", ["install", ...PKG_SPECS.map(authUrl), "--legacy-peer-deps"], 3, { cwd: rt, env });
247
344
  let manifest = { pkgSpecs: PKG_SPECS, peers: PEERS };
248
345
  try { manifest = { ...JSON.parse(fs.readFileSync(runtimeManifestPath(), "utf8")), ...manifest }; } catch {}
249
346
  manifest.refreshedAt = new Date().toISOString();
@@ -339,7 +436,7 @@ async function installCaloDeps(cwd) {
339
436
  // --legacy-peer-deps: the Expo/RN dep graph routinely has benign peerOptional conflicts
340
437
  // (e.g. overlapping typescript ranges); without it a single conflict ERESOLVEs the whole install.
341
438
  const env = gitTokenEnv(await githubToken());
342
- runWithRetry("npm", ["install", ...PKG_SPECS, "--legacy-peer-deps"], 3, { cwd, env });
439
+ runWithRetry("npm", ["install", ...PKG_SPECS.map(authUrl), "--legacy-peer-deps"], 3, { cwd, env });
343
440
  if (isExpoProject(cwd)) run("npx", ["expo", "install", ...PEERS, "expo-font"], { cwd });
344
441
  else {
345
442
  warn("non-Expo project — installing latest peers; pin them to match your React Native version if needed.");
@@ -564,6 +661,9 @@ function nextSteps({ linked, cd } = {}) {
564
661
  }
565
662
 
566
663
  async function cmdInit() {
664
+ // Only on a first run: the people who need the provenance are the ones who haven't
665
+ // logged in yet. Returning users have already made up their minds about us.
666
+ if (!loadSession()) provenance();
567
667
  await ensureLoggedIn();
568
668
  await installSkill();
569
669
  if (has("--skip-packages")) return nextSteps({});
@@ -791,7 +891,7 @@ function help() {
791
891
  ${c.dim("open <owner>/<slug>[@v] [folder]")} unpack a teammate's checkpoint; your first save becomes your own fork
792
892
  ${c.dim("projects")} all shared prototypes (--mine, --json)
793
893
  ${c.dim("backend init --recipe <name>")} provision THIS prototype a real backend (Cloudflare worker + data; login only)
794
- ${c.dim(" backend add <d1|kv|r2> deploy status logs --since 10m --errors sql \"…\" check secrets set K --value v rotate-key")}
894
+ ${c.dim(" backend add <d1|kv|r2|ai> deploy status logs --since 10m --errors sql \"…\" check secrets set K --value v rotate-key ai-disable")}
795
895
  ${c.dim("feedback [slug]")} feedback inbox for shared prototypes (open threads; --all, --json)
796
896
  ${c.dim(" feedback new <slug> <text> comment|resolve|reopen <threadId> shot <slug> <png>")}
797
897
  ${c.dim("tasks [slug]")} triaged work items (--status todo|in_progress|done|wontfix, --json)
@@ -807,7 +907,7 @@ function help() {
807
907
  (async () => {
808
908
  try {
809
909
  if (cmd === "--version" || cmd === "-v" || cmd === "version") { console.log(require("../package.json").version); return; }
810
- if (cmd === "login") await cmdLogin(args.slice(1));
910
+ if (cmd === "login") { provenance(); await cmdLogin(args.slice(1)); }
811
911
  else if (cmd === "logout") cmdLogout();
812
912
  else if (cmd === "init") await cmdInit();
813
913
  else if (cmd === "update") await cmdUpdate();
package/bin/login.js CHANGED
@@ -99,10 +99,14 @@ async function cmdLogin(args = []) {
99
99
  // Request a code. (Pass --code to skip the send, e.g. a code you already have.)
100
100
  await api("/v1/login/start", { email });
101
101
  if (!interactive) {
102
- console.log(`Sent a 6-digit code to ${email}. Now run: npx @calo-design/cli login --email=${email} --code=<the 6-digit code>`);
102
+ console.log(`Sent a 6-digit code to ${email} from ${BROKER} (Calo's own service).`);
103
+ console.log("The code proves you can read Calo mail; it authorizes this CLI only — it is not a");
104
+ console.log("GitHub, Google, or SSO login, and it grants nothing beyond read access to Calo's");
105
+ console.log("private design repos. Ask the person for the code, then run:");
106
+ console.log(` npx @calo-design/cli login --email=${email} --code=<the 6-digit code>`);
103
107
  return;
104
108
  }
105
- console.log(`We emailed a 6-digit code to ${email}.`);
109
+ console.log(`We emailed a 6-digit code to ${email} from ${BROKER} (Calo's own service).`);
106
110
  code = await ask("Code: ");
107
111
  }
108
112
  const r = await api("/v1/login/verify", { email, code });
@@ -25,6 +25,26 @@ const SHARED_PROJECT_ID = "290a759f-427c-432e-9ab5-dab98310e66b";
25
25
  const UPDATES_URL = `https://u.expo.dev/${SHARED_PROJECT_ID}`;
26
26
  const RUNTIME_VERSION = "0.2.0"; // must equal the shell binary's runtimeVersion (SDK 57 shell, version 0.2.0)
27
27
  const LAUNCHER_CHANNEL = "mirror";
28
+ // Native modules COMPILED INTO the Mirror shell binary (runtimeVersion 0.2.0, iOS
29
+ // build 11+). This — not the shared runtime — is the authority on what a pushed
30
+ // prototype may import: a push ships JS only, so a package whose native side isn't
31
+ // in the binary throws the moment its module scope runs `requireNativeModule`.
32
+ // Metro then SWALLOWS that throw (guardedLoadModule → ErrorUtils.reportFatalError,
33
+ // returning undefined), so expo-router destructures undefined and the phone shows
34
+ // "Cannot read property 'ErrorBoundary' of undefined" — an error that points
35
+ // nowhere near the missing module. That's why this list exists and why the
36
+ // preflight below is an error, not a warning. Keep it in lockstep with
37
+ // calo-design-mirror/package.json whenever a new shell binary ships.
38
+ const SHELL_MODULES = new Set([
39
+ "@expo/ui", "@gorhom/bottom-sheet", "@microsoft/react-native-clarity", "@sentry/react-native",
40
+ "expo", "expo-asset", "expo-camera", "expo-clipboard", "expo-constants", "expo-device",
41
+ "expo-font", "expo-glass-effect", "expo-image", "expo-linking", "expo-router",
42
+ "expo-splash-screen", "expo-status-bar", "expo-symbols", "expo-system-ui", "expo-updates",
43
+ "expo-video", "expo-web-browser", "phosphor-react-native", "react", "react-dom",
44
+ "react-native", "react-native-gesture-handler", "react-native-qrcode-svg",
45
+ "react-native-reanimated", "react-native-safe-area-context", "react-native-screens",
46
+ "react-native-svg", "react-native-web", "react-native-worklets",
47
+ ]);
28
48
  // Crash reporting: every pushed prototype gets a Sentry init + error boundary
29
49
  // injected (see injectBackChrome). Same project as the shell's own init
30
50
  // (calo-design-mirror expo.extra.sentryDsn) — keep the two DSNs in sync. A DSN
@@ -140,8 +160,10 @@ function findScreenshot(root, explicit) {
140
160
 
141
161
  // ---- staging ----------------------------------------------------------------
142
162
 
143
- // The prototype's deps must be a subset of the shared runtime — the shell binary
144
- // only contains the runtime's native modules, so anything extra would be missing.
163
+ // The prototype's deps must be a subset of the shared runtime — it's what the staged
164
+ // node_modules symlink points at, so anything extra simply isn't there to bundle.
165
+ // This is about BUILDING the update; whether the phone can RUN it is a separate,
166
+ // stricter question answered by preflightNativeModules (SHELL_MODULES).
145
167
  function validateDeps(pkg) {
146
168
  const runtimePkgPath = path.join(runtimeDir(), "package.json");
147
169
  if (!fs.existsSync(runtimePkgPath)) {
@@ -288,6 +310,71 @@ function preflightAssetCount(stage) {
288
310
  }
289
311
  }
290
312
 
313
+ // ---- native-module preflight ------------------------------------------------
314
+ // A push ships JS; native code only ever arrives with a new shell BINARY. The
315
+ // shared runtime is deliberately wider than the binary at times (a package can
316
+ // land in the runtime before the next TestFlight build), and importing one of
317
+ // those crashes the prototype at startup with an error that names expo-router,
318
+ // not the module — see SHELL_MODULES. Catch it here, where the fix is obvious.
319
+ //
320
+ // "Native" is derived from the runtime itself rather than hard-coded: a package
321
+ // is native when it carries an Expo module config or a podspec.
322
+ const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
323
+ // Package name from a specifier: "expo-image/build/x" → "expo-image", "@expo/ui/swift-ui" → "@expo/ui".
324
+ const packageOf = (spec) => {
325
+ if (spec.startsWith(".") || spec.startsWith("/")) return null;
326
+ const parts = spec.split("/");
327
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
328
+ };
329
+
330
+ function isNativePackage(name) {
331
+ const dir = path.join(runtimeDir(), "node_modules", name);
332
+ if (fs.existsSync(path.join(dir, "expo-module.config.json"))) return true;
333
+ try {
334
+ return fs.readdirSync(dir).some((f) => f.endsWith(".podspec"));
335
+ } catch {
336
+ return false; // not installed in the runtime — validateDeps already covered that
337
+ }
338
+ }
339
+
340
+ function stagedImports(stage) {
341
+ const found = new Set();
342
+ const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(["'])([^"']+)\1/g;
343
+ const walk = (dir) => {
344
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
345
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
346
+ const p = path.join(dir, e.name);
347
+ if (e.isSymbolicLink()) continue; // never follow into the runtime symlink
348
+ if (e.isDirectory()) walk(p);
349
+ else if (SOURCE_EXTS.has(path.extname(e.name))) {
350
+ const src = fs.readFileSync(p, "utf8");
351
+ for (const m of src.matchAll(re)) {
352
+ const pkg = packageOf(m[2]);
353
+ if (pkg) found.add(pkg);
354
+ }
355
+ }
356
+ }
357
+ };
358
+ walk(stage);
359
+ return found;
360
+ }
361
+
362
+ function preflightNativeModules(stage) {
363
+ const missing = [...stagedImports(stage)]
364
+ .filter((pkg) => !SHELL_MODULES.has(pkg) && isNativePackage(pkg))
365
+ .sort();
366
+ if (!missing.length) return;
367
+ throw new Error(
368
+ `this prototype imports native modules the Mirror shell binary doesn't contain:\n` +
369
+ ` ${missing.join(", ")}\n` +
370
+ ` A push ships JavaScript only — native code arrives with a new Mirror build, so on the phone\n` +
371
+ ` these throw at startup and the prototype dies with a misleading expo-router error\n` +
372
+ ` ("Cannot read property 'ErrorBoundary' of undefined").\n` +
373
+ ` Swap them for something in the shared runtime the shell provides, or ask for a shell build\n` +
374
+ ` that includes them (calo-design-mirror → TestFlight, then update SHELL_MODULES here).`
375
+ );
376
+ }
377
+
291
378
  function writeMetroConfig(stage) {
292
379
  const runtimeReal = fs.realpathSync(runtimeDir());
293
380
  fs.writeFileSync(
@@ -883,6 +970,7 @@ async function cmdPush(args) {
883
970
  try {
884
971
  stageProject({ root, stage, slug, title, ad, owner });
885
972
  preflightAssetCount(stage);
973
+ preflightNativeModules(stage);
886
974
 
887
975
  if (dry) {
888
976
  keepStage = true;
@@ -1007,4 +1095,4 @@ async function cmdPush(args) {
1007
1095
  }
1008
1096
  }
1009
1097
 
1010
- module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry, _publicBase: PUBLIC_BASE, _extractEasGroup: extractEasGroup, _versionBranchOf: versionBranchOf };
1098
+ module.exports = { cmdPush, _stageSkipDirs: STAGE_SKIP_DIRS, _s3Put: s3Put, _upsertRegistry: upsertRegistry, _publicBase: PUBLIC_BASE, _extractEasGroup: extractEasGroup, _versionBranchOf: versionBranchOf, _stagedImports: stagedImports, _preflightNativeModules: preflightNativeModules, _shellModules: SHELL_MODULES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.13.6",
3
+ "version": "0.14.0",
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"