@calo-design/cli 0.13.5 → 0.13.7

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.
Files changed (3) hide show
  1. package/bin/backend.js +62 -7
  2. package/bin/cli.js +112 -12
  3. package/package.json +1 -1
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
  }
@@ -253,7 +263,12 @@ async function cmdDeploy(args) {
253
263
  if (out.migrations && out.migrations.ran) log(c.dim(" migrations applied"));
254
264
  log(c.dim(` next: calo-design backend check`));
255
265
  // Pair the deploy with its exact source — best-effort by contract, never fails the deploy.
256
- await autoCheckpoint({ root, note: `backend deploy ${out.deployHash.slice(0, 8)}`, publishedUrl: out.url });
266
+ // --note labels the checkpoint too (same contract as share/push).
267
+ await autoCheckpoint({
268
+ root,
269
+ note: flag(args, "note") || `backend deploy ${out.deployHash.slice(0, 8)}`,
270
+ publishedUrl: out.url,
271
+ });
257
272
  } finally {
258
273
  fs.rmSync(tgz, { force: true });
259
274
  }
@@ -278,7 +293,18 @@ async function cmdStatus(args) {
278
293
  log(` worker ${s.workerUrl}${s.deployHash ? "" : c.dim(" (never deployed)")}`);
279
294
  if (s.deployHash) log(` deploy ${s.deployHash.slice(0, 12)} ${c.dim(new Date(s.deployedAt).toISOString())}`);
280
295
  for (const r of s.resources) log(` ${r.kind.padEnd(8)} ${r.name}`);
281
- 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>`"));
282
308
  }
283
309
 
284
310
  const parseSince = (v) => {
@@ -420,6 +446,33 @@ async function cmdAll(args) {
420
446
  }
421
447
  }
422
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
+
423
476
  async function cmdBackend(args) {
424
477
  const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
425
478
  const rest = args.slice(1);
@@ -433,8 +486,10 @@ async function cmdBackend(args) {
433
486
  if (sub === "check") return cmdCheck(rest);
434
487
  if (sub === "secrets") return cmdSecrets(rest);
435
488
  if (sub === "rotate-key") return cmdRotateKey(rest);
489
+ if (sub === "ai-disable") return cmdAiDisable(rest);
490
+ if (sub === "ai-usage") return cmdAiUsage(rest);
436
491
  throw new Error(
437
- "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"
438
493
  );
439
494
  }
440
495
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
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"