@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 +18 -0
- package/bin/backend.js +56 -6
- package/bin/checkpoints.js +59 -3
- package/bin/cli.js +112 -12
- package/bin/login.js +6 -2
- package/bin/mirror-push.js +91 -3
- package/package.json +1 -1
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
|
|
109
|
-
// there's no recipe.json to read — a fork of ANY recipe
|
|
110
|
-
// not just 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 (
|
|
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
|
|
package/bin/checkpoints.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
120
|
-
|
|
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}
|
|
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 });
|
package/bin/mirror-push.js
CHANGED
|
@@ -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
|
|
144
|
-
//
|
|
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.
|
|
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"
|