@calo-design/cli 0.4.7 → 0.5.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.
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `calo-design art push <dir>` — upload a bulk image set to the Tigris CDN and
5
+ * emit a generated manifest module of remote { uri, width, height } sources.
6
+ *
7
+ * Why: Metro statically collects every `require()`d image into the EAS update,
8
+ * and EAS caps updates at 1000 assets. Bulk/generated art (BYO component art,
9
+ * photo sets, …) must therefore be REMOTE — a URI is just a string in the JS
10
+ * bundle and counts for nothing. This command is how art becomes remote:
11
+ *
12
+ * calo-design art push ./art/byo --set byo
13
+ * → art/byo/<relpath>.<hash12>.png in the calo-design-mirror bucket
14
+ * → ./byo-art.ts manifest: { "drive/avocado": { uri, width, height }, … }
15
+ *
16
+ * Keys are content-hashed, so re-runs are incremental (existing objects are
17
+ * skipped via public HEAD) and objects are immutable → served with a 1-year
18
+ * cache-control. Changing a file changes its hash, its key, and its URI.
19
+ *
20
+ * Rule of thumb: small always-on sets (DS core visuals, icons) stay local
21
+ * `require()`s for offline-safety; anything bulk goes through here.
22
+ *
23
+ * Default path is the broker (login only — same trust model as `push`: storage
24
+ * creds never reach a client). `--direct` uploads straight from this machine with
25
+ * local Tigris creds (`source ~/.designchef/mirror.env`), for the maintainer.
26
+ */
27
+
28
+ const crypto = require("node:crypto");
29
+ const fs = require("node:fs");
30
+ const path = require("node:path");
31
+ const { _s3Put: s3Put, _publicBase: PUBLIC_BASE } = require("./mirror-push");
32
+ const { ensureLoggedIn, publishArt } = require("./login");
33
+
34
+ const c = {
35
+ dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`,
36
+ g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`,
37
+ };
38
+ const log = (s = "") => console.log(s);
39
+ const ok = (s) => log(`${c.g("✓")} ${s}`);
40
+ const warn = (s) => log(`${c.y("!")} ${s}`);
41
+
42
+ const flag = (args, name, def) => {
43
+ const i = args.indexOf(name);
44
+ return i >= 0 && args[i + 1] ? args[i + 1] : def;
45
+ };
46
+ const has = (args, name) => args.includes(name);
47
+
48
+ const IMAGE_TYPES = {
49
+ ".png": "image/png",
50
+ ".jpg": "image/jpeg",
51
+ ".jpeg": "image/jpeg",
52
+ ".webp": "image/webp",
53
+ ".gif": "image/gif",
54
+ };
55
+
56
+ // ---- intrinsic pixel dimensions (no deps — header parsing per format) -------
57
+ // RN can't infer sizes for remote images the way it does for require()d ones,
58
+ // so the manifest bakes them in. These are PIXEL dims; @2x/@3x/-retina naming
59
+ // is the author's density convention and is left to the consumer's styles.
60
+
61
+ function pngSize(buf) {
62
+ // 8-byte signature, then IHDR: width/height at offsets 16/20 (big-endian).
63
+ if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) return null;
64
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
65
+ }
66
+
67
+ function jpegSize(buf) {
68
+ if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffd8) return null;
69
+ let p = 2;
70
+ while (p + 9 < buf.length) {
71
+ if (buf[p] !== 0xff) { p++; continue; }
72
+ const marker = buf[p + 1];
73
+ // SOF0–SOF15 carry dimensions, except DHT(C4)/JPG(C8)/DAC(CC).
74
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
75
+ return { height: buf.readUInt16BE(p + 5), width: buf.readUInt16BE(p + 7) };
76
+ }
77
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { p += 2; continue; }
78
+ p += 2 + buf.readUInt16BE(p + 2);
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function webpSize(buf) {
84
+ if (buf.length < 30 || buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") return null;
85
+ const fourcc = buf.toString("ascii", 12, 16);
86
+ if (fourcc === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
87
+ if (fourcc === "VP8L") {
88
+ const b = buf.readUInt32LE(21);
89
+ return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 };
90
+ }
91
+ if (fourcc === "VP8X") return { width: buf.readUIntLE(24, 3) + 1, height: buf.readUIntLE(27, 3) + 1 };
92
+ return null;
93
+ }
94
+
95
+ function gifSize(buf) {
96
+ if (buf.length < 10 || buf.toString("ascii", 0, 3) !== "GIF") return null;
97
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
98
+ }
99
+
100
+ function imageSize(buf, ext) {
101
+ const fn = { ".png": pngSize, ".jpg": jpegSize, ".jpeg": jpegSize, ".webp": webpSize, ".gif": gifSize }[ext];
102
+ return (fn && fn(buf)) || null;
103
+ }
104
+
105
+ // ---- helpers ----------------------------------------------------------------
106
+
107
+ function walkImages(root) {
108
+ const out = [];
109
+ const walk = (dir) => {
110
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
111
+ if (e.name.startsWith(".")) continue;
112
+ const p = path.join(dir, e.name);
113
+ if (e.isDirectory()) walk(p);
114
+ else if (IMAGE_TYPES[path.extname(e.name).toLowerCase()]) out.push(p);
115
+ }
116
+ };
117
+ walk(root);
118
+ return out;
119
+ }
120
+
121
+ // "Drive/Avocado Half.png" → "drive/avocado-half" (manifest key + key slug)
122
+ const slugPath = (rel) =>
123
+ rel.split(path.sep).map((seg) =>
124
+ seg.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")
125
+ ).filter(Boolean).join("/");
126
+
127
+ async function pool(items, n, fn) {
128
+ const it = items.entries();
129
+ const workers = Array.from({ length: Math.min(n, items.length) }, async () => {
130
+ for (let x = it.next(); !x.done; x = it.next()) await fn(x.value[1], x.value[0]);
131
+ });
132
+ await Promise.all(workers);
133
+ }
134
+
135
+ const camel = (s) => s.replace(/[^a-zA-Z0-9]+(.)/g, (_, ch) => ch.toUpperCase()).replace(/^[^a-zA-Z_]+/, "");
136
+
137
+ function manifestSource({ setName, dir, entries }) {
138
+ const exportName = `${camel(setName)}Art`;
139
+ const lines = entries.map(
140
+ ({ key, uri, width, height }) =>
141
+ ` ${JSON.stringify(key)}: { uri: ${JSON.stringify(uri)}, width: ${width}, height: ${height} },`
142
+ );
143
+ return `// Generated by \`calo-design art push\` — do not edit by hand.
144
+ // Set: ${setName} Source: ${dir} Assets: ${entries.length}
145
+ //
146
+ // Remote CDN art (Fly Tigris, edge-cached, immutable content-hashed keys).
147
+ // These are plain { uri } sources: they ship as strings, never as EAS update
148
+ // assets, so this set doesn't count against the 1000-asset update limit.
149
+ // width/height are intrinsic PIXEL dims — set display size in styles as usual.
150
+
151
+ export type CaloArtAsset = { uri: string; width: number; height: number };
152
+
153
+ export const ${exportName} = {
154
+ ${lines.join("\n")}
155
+ } as const;
156
+
157
+ export type ${camel(`${setName}-art-name`).replace(/^./, (m) => m.toUpperCase())} = keyof typeof ${exportName};
158
+ `;
159
+ }
160
+
161
+ // ---- command ----------------------------------------------------------------
162
+
163
+ async function cmdArtPush(args) {
164
+ // First positional = the art directory (skip --flags; --set/--manifest take a value).
165
+ const VALUE_FLAGS = new Set(["--set", "--manifest"]);
166
+ let dirArg;
167
+ for (let i = 0; i < args.length; i++) {
168
+ if (args[i].startsWith("--")) { if (VALUE_FLAGS.has(args[i])) i++; continue; }
169
+ dirArg = args[i];
170
+ break;
171
+ }
172
+ const dir = path.resolve(dirArg || ".");
173
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
174
+ throw new Error(`not a directory: ${dir}\n Usage: calo-design art push <dir> [--set name] [--manifest out.ts] [--dry-run]`);
175
+ }
176
+
177
+ const setName = slugPath(flag(args, "--set", path.basename(dir))) || "art";
178
+ const manifestPath = path.resolve(flag(args, "--manifest", `${setName}-art.ts`));
179
+ const dry = has(args, "--dry-run");
180
+ const force = has(args, "--force"); // re-upload even when the object already exists
181
+ const direct = has(args, "--direct"); // legacy: upload from this machine's own Tigris creds
182
+
183
+ const files = walkImages(dir);
184
+ if (!files.length) throw new Error(`no images found under ${dir} (looked for ${Object.keys(IMAGE_TYPES).join(" ")})`);
185
+
186
+ log(c.b(`\n[art push] ${setName}`) + c.dim(` ${files.length} images from ${dir}`));
187
+
188
+ // Hash + measure everything up front so collisions and unreadable files fail
189
+ // before any upload starts.
190
+ const entries = [];
191
+ const byKey = new Map();
192
+ let totalBytes = 0;
193
+ for (const file of files) {
194
+ const rel = path.relative(dir, file);
195
+ const ext = path.extname(file).toLowerCase();
196
+ const buf = fs.readFileSync(file);
197
+ const size = imageSize(buf, ext);
198
+ if (!size) { warn(`skipping ${rel} — couldn't parse ${ext} header`); continue; }
199
+ if (buf.length > 8e6) throw new Error(`${rel} is ${(buf.length / 1e6).toFixed(1)} MB — over the 8 MB per-image cap. Resize/compress it (device screens never need more).`);
200
+ if (buf.length > 4e6) warn(`${rel} is ${(buf.length / 1e6).toFixed(1)} MB — that's heavy for on-device loading; consider resizing/compressing.`);
201
+ const hash = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 12);
202
+ const key = slugPath(rel.slice(0, -ext.length));
203
+ const prev = byKey.get(key);
204
+ if (prev) throw new Error(`manifest key collision: "${key}" from both ${prev.rel} and ${rel} — rename one.`);
205
+ const objectKey = `art/${setName}/${key}.${hash}${ext}`;
206
+ const entry = { rel, key, buf, objectKey, contentType: IMAGE_TYPES[ext], uri: `${PUBLIC_BASE}/${objectKey}`, ...size };
207
+ byKey.set(key, entry);
208
+ entries.push(entry);
209
+ totalBytes += buf.length;
210
+ }
211
+ if (!entries.length) throw new Error("nothing uploadable — every file failed header parsing.");
212
+ ok(`hashed + measured ${entries.length} images (${(totalBytes / 1e6).toFixed(1)} MB)`);
213
+
214
+ if (dry) {
215
+ for (const e of entries.slice(0, 10)) log(c.dim(` ${e.key} ${e.width}×${e.height} → ${e.objectKey}`));
216
+ if (entries.length > 10) log(c.dim(` … and ${entries.length - 10} more`));
217
+ log(c.dim(` would write manifest: ${manifestPath}`));
218
+ return;
219
+ }
220
+
221
+ // Incremental: content-hashed keys are immutable, so a public HEAD hit means
222
+ // the exact bytes are already live and the upload can be skipped. The HEAD is a
223
+ // plain public read, so it works on both paths (broker and --direct).
224
+ let reused = 0, done = 0;
225
+ const pending = [];
226
+ const label = (phase) => process.stdout.write(`\r ${phase}… ${done}/${entries.length}`);
227
+ await pool(entries, 8, async (e) => {
228
+ const exists = !force && (await fetch(e.uri, { method: "HEAD" })).ok;
229
+ if (exists) reused++;
230
+ else pending.push(e);
231
+ done++;
232
+ label("checking");
233
+ });
234
+ process.stdout.write("\r");
235
+
236
+ let uploaded = 0;
237
+ if (!pending.length) {
238
+ ok(`nothing to upload — all ${entries.length} assets already live`);
239
+ } else if (direct) {
240
+ // Maintainer path: signed PUTs straight to Tigris with local creds.
241
+ done = 0;
242
+ await pool(pending, 8, async (e) => {
243
+ await s3Put(e.objectKey, e.buf, e.contentType, { "cache-control": "public, max-age=31536000, immutable" });
244
+ uploaded++; done++;
245
+ label("uploading");
246
+ });
247
+ process.stdout.write("\r");
248
+ ok(`uploaded ${uploaded} asset${uploaded === 1 ? "" : "s"} (direct)${reused ? ` — reused ${reused} already-live` : ""}`);
249
+ } else {
250
+ // Default path: no local secrets. Batch through the broker, which performs the
251
+ // Tigris writes server-side (≤32 files and ≤16 MB raw per request — the broker
252
+ // caps the JSON envelope at 24 MB and 64 files).
253
+ await ensureLoggedIn();
254
+ const batches = [];
255
+ let batch = [], batchBytes = 0;
256
+ for (const e of pending) {
257
+ if (batch.length >= 32 || (batchBytes + e.buf.length > 16e6 && batch.length)) {
258
+ batches.push(batch); batch = []; batchBytes = 0;
259
+ }
260
+ batch.push(e); batchBytes += e.buf.length;
261
+ }
262
+ if (batch.length) batches.push(batch);
263
+ done = 0;
264
+ for (const b of batches) {
265
+ await publishArt(b.map((e) => ({ key: e.objectKey, contentType: e.contentType, dataBase64: e.buf.toString("base64") })));
266
+ uploaded += b.length; done += b.length;
267
+ label("uploading");
268
+ }
269
+ process.stdout.write("\r");
270
+ ok(`uploaded ${uploaded} asset${uploaded === 1 ? "" : "s"} via broker${reused ? ` — reused ${reused} already-live` : ""}`);
271
+ }
272
+
273
+ fs.writeFileSync(manifestPath, manifestSource({ setName, dir: path.relative(process.cwd(), dir) || ".", entries }));
274
+ ok(`manifest → ${path.relative(process.cwd(), manifestPath)}`);
275
+
276
+ log(c.b("\n✨ Art is live on the CDN.") + c.dim(" Import the manifest and pass entries straight to <Image source={…}>."));
277
+ log(c.dim(` e.g. import { ${camel(setName)}Art } from "./${path.basename(manifestPath, ".ts")}";`));
278
+ log(c.dim(` <Image source={${camel(setName)}Art[${JSON.stringify(entries[0].key)}]} style={{ width: 96, height: 96 }} />`));
279
+ }
280
+
281
+ module.exports = { cmdArtPush, _imageSize: imageSize, _slugPath: slugPath, _manifestSource: manifestSource };
package/bin/cli.js CHANGED
@@ -16,6 +16,7 @@ const fs = require("node:fs");
16
16
  const os = require("node:os");
17
17
  const path = require("node:path");
18
18
  const { cmdPush } = require("./mirror-push");
19
+ const { cmdArtPush } = require("./art-push");
19
20
  const { cmdShare } = require("./share");
20
21
  const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
21
22
 
@@ -35,7 +36,12 @@ const PEERS = [
35
36
  "expo-updates",
36
37
  // Video + bundled-asset support (Mirror shell ships the native side from build 7).
37
38
  "expo-video",
38
- "expo-asset"
39
+ "expo-asset",
40
+ // Crash reporting: `push` injects a Sentry init + error boundary into every
41
+ // prototype (see mirror-push.js mirrorChromeSource). The shell binary provides
42
+ // the native module (from build 8); prototypes ship JS only, and on older
43
+ // binaries the SDK falls back to JS-only transport.
44
+ "@sentry/react-native"
39
45
  ];
40
46
  // Install via explicit HTTPS git URLs. Auth is a short-lived GitHub token the Calo
41
47
  // broker mints after `calo-design login` — injected into git for the install
@@ -55,8 +61,17 @@ const ok = (s) => log(`${c.g("✓")} ${s}`);
55
61
  const warn = (s) => log(`${c.y("!")} ${s}`);
56
62
  const tilde = (p) => p.replace(os.homedir(), "~");
57
63
 
64
+ // npm/npx are .cmd shims on Windows, and Node >= 18.20.2 (CVE-2024-27980)
65
+ // refuses to spawn .cmd files without shell:true. With shell:true cmd.exe
66
+ // gets the args space-joined and unescaped, so quote anything with whitespace.
67
+ function winShell(bin, argv) {
68
+ if (process.platform !== "win32") return [bin, argv, false];
69
+ const q = (s) => (/[ \t]/.test(s) ? `"${s}"` : s);
70
+ return [q(bin), argv.map(q), true];
71
+ }
58
72
  function run(bin, argv, opts = {}) {
59
- const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
73
+ const [b, a, shell] = winShell(bin, argv);
74
+ const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
60
75
  if (r.error) throw r.error;
61
76
  if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
62
77
  return r;
@@ -68,7 +83,8 @@ function sleep(sec) {
68
83
  // races / rate limits) and recover on retry. Retry before giving up.
69
84
  function runWithRetry(bin, argv, tries = 3, opts = {}) {
70
85
  for (let i = 1; i <= tries; i++) {
71
- const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
86
+ const [b, a, shell] = winShell(bin, argv);
87
+ const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
72
88
  if (!r.error && r.status === 0) return r;
73
89
  if (i < tries) { warn(`install hiccup — retrying (${i}/${tries - 1})…`); sleep(3); }
74
90
  }
@@ -752,6 +768,8 @@ function help() {
752
768
  ${c.dim("logout")} forget the saved Calo session
753
769
  ${c.dim("push")} publish THIS prototype to the Calo Mirror (login only — no EAS/Tigris creds needed)
754
770
  ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run --direct")}
771
+ ${c.dim("art push <dir>")} upload a bulk image set to the CDN + emit a remote-source manifest (login only)
772
+ ${c.dim(" art push ./art/byo --set byo --manifest src/byo-art.ts --dry-run --force --direct")}
755
773
  ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
756
774
  ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
757
775
  ${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
@@ -772,6 +790,8 @@ function help() {
772
790
  else if (cmd === "init") await cmdInit();
773
791
  else if (cmd === "update") await cmdUpdate();
774
792
  else if (cmd === "push") await cmdPush(args.slice(1));
793
+ else if (cmd === "art" && args[1] === "push") await cmdArtPush(args.slice(2));
794
+ else if (cmd === "art") throw new Error("unknown art command — did you mean `calo-design art push <dir>`?");
775
795
  else if (cmd === "share") await cmdShare(args.slice(1));
776
796
  else if (cmd === "feed") await cmdFeed();
777
797
  else if (cmd === "insights") await cmdInsights();
package/bin/login.js CHANGED
@@ -193,6 +193,13 @@ async function publishToMirror(payload) {
193
193
  return api("/v1/publish", payload, session);
194
194
  }
195
195
 
196
+ // Hand a batch of content-hashed art files to the broker, which performs the Tigris
197
+ // writes server-side (same trust model as publishToMirror — no storage creds locally).
198
+ async function publishArt(files) {
199
+ const session = await ensureSession();
200
+ return api("/v1/publish-art", { files }, session);
201
+ }
202
+
196
203
  // Best-effort design-ops telemetry: report an init/update to the broker feed (The Pass).
197
204
  // Never throws and never blocks the command on failure — telemetry is not the job. Uses the
198
205
  // existing session if it's still valid; won't trigger a refresh just to report an event.
@@ -206,4 +213,4 @@ async function reportEvent(action, target) {
206
213
  }
207
214
  }
208
215
 
209
- module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishWeb, publishToMirror, reportEvent, BROKER, loadSession };
216
+ module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishWeb, publishToMirror, publishArt, reportEvent, BROKER, loadSession };
@@ -25,6 +25,12 @@ 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.1.0"; // must equal the shell binary's runtimeVersion (appVersion policy, version 0.1.0)
27
27
  const LAUNCHER_CHANNEL = "mirror";
28
+ // Crash reporting: every pushed prototype gets a Sentry init + error boundary
29
+ // injected (see injectBackChrome). Same project as the shell's own init
30
+ // (calo-design-mirror expo.extra.sentryDsn) — keep the two DSNs in sync. A DSN
31
+ // is a public event-ingest identifier, not a secret. Empty => no injection.
32
+ const SENTRY_DSN = process.env.CALO_MIRROR_SENTRY_DSN ||
33
+ "https://20090c120924d4952c564611ac08afdc@o4510813973577728.ingest.us.sentry.io/4511807869616128";
28
34
 
29
35
  // ---- registry (Fly Tigris, S3-compatible; override via env) -----------------
30
36
  const BUCKET = process.env.CALO_MIRROR_BUCKET || "calo-design-mirror";
@@ -50,14 +56,24 @@ const flag = (args, name, def) => {
50
56
  };
51
57
  const has = (args, name) => args.includes(name);
52
58
 
59
+ // npm/npx/eas are .cmd shims on Windows, and Node >= 18.20.2 (CVE-2024-27980)
60
+ // refuses to spawn .cmd files without shell:true. With shell:true cmd.exe
61
+ // gets the args space-joined and unescaped, so quote anything with whitespace.
62
+ function winShell(bin, argv) {
63
+ if (process.platform !== "win32") return [bin, argv, false];
64
+ const q = (s) => (/[ \t]/.test(s) ? `"${s}"` : s);
65
+ return [q(bin), argv.map(q), true];
66
+ }
53
67
  function run(bin, argv, opts = {}) {
54
- const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
68
+ const [b, a, shell] = winShell(bin, argv);
69
+ const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
55
70
  if (r.error) throw r.error;
56
71
  if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
57
72
  return r;
58
73
  }
59
74
  function capture(bin, argv, opts = {}) {
60
- const r = spawnSync(bin, argv, { encoding: "utf8", ...opts });
75
+ const [b, a, shell] = winShell(bin, argv);
76
+ const r = spawnSync(b, a, { encoding: "utf8", shell, ...opts });
61
77
  return { status: r.status == null ? 1 : r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
62
78
  }
63
79
 
@@ -143,7 +159,7 @@ const STAGE_SKIP_DIRS = new Set([
143
159
  "ios", "android", ".tamagui", ".vscode", ".idea",
144
160
  ]);
145
161
 
146
- function stageProject({ root, stage, slug, title, ad }) {
162
+ function stageProject({ root, stage, slug, title, ad, owner }) {
147
163
  // Copy the author's whole project (minus STAGE_SKIP_DIRS). Managed files
148
164
  // (app.json, package.json, tsconfig, babel, metro, node_modules, .gitignore,
149
165
  // the root _layout) are overwritten below, exactly as before.
@@ -196,7 +212,7 @@ function stageProject({ root, stage, slug, title, ad }) {
196
212
 
197
213
  writeMetroConfig(stage);
198
214
  linkNodeModules(stage);
199
- injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel });
215
+ injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel, slug, owner });
200
216
 
201
217
  // EAS Update enumerates project files via git. Give the throwaway staging dir
202
218
  // its own local repo (node_modules ignored). This never leaves the temp dir —
@@ -207,6 +223,49 @@ function stageProject({ root, stage, slug, title, ad }) {
207
223
  run("git", ["-C", stage, "-c", "user.email=mirror@calo.app", "-c", "user.name=Calo Mirror", "commit", "-q", "-m", "mirror push"]);
208
224
  }
209
225
 
226
+ // ---- asset-count preflight --------------------------------------------------
227
+ // EAS hard-caps updates at 1000 assets per platform (server-side; not a plan
228
+ // setting). Metro collects every statically-reachable require(), and the shared
229
+ // DS ships ~230 assets (visual library + fonts) with EVERY prototype, so a
230
+ // designer who drops a big image set into their prototype would otherwise sit
231
+ // through the full stage + export + upload only to get a cryptic server
232
+ // rejection. Count the prototype's image files up front and speak up BEFORE
233
+ // eas update runs. File count is an upper bound (unreferenced files don't
234
+ // bundle), so over-the-line is a loud warning, not a hard block.
235
+ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".svg"]);
236
+ const DS_BASELINE_ASSETS = 230; // @calo/design-system visuals + fonts, bundled with every push
237
+ const EAS_ASSET_LIMIT = 1000;
238
+
239
+ function countStagedImages(stage) {
240
+ let n = 0;
241
+ const walk = (dir) => {
242
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
243
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
244
+ const p = path.join(dir, e.name);
245
+ if (e.isSymbolicLink()) continue; // never follow into the runtime symlink
246
+ if (e.isDirectory()) walk(p);
247
+ else if (IMAGE_EXTS.has(path.extname(e.name).toLowerCase())) n++;
248
+ }
249
+ };
250
+ walk(stage);
251
+ return n;
252
+ }
253
+
254
+ function preflightAssetCount(stage) {
255
+ const own = countStagedImages(stage);
256
+ const estimate = own + DS_BASELINE_ASSETS;
257
+ if (estimate > EAS_ASSET_LIMIT) {
258
+ warn(`this prototype has ${own} image files; with the ~${DS_BASELINE_ASSETS} design-system assets every push carries, ` +
259
+ `that's ~${estimate} — OVER the EAS limit of ${EAS_ASSET_LIMIT} assets per update.`);
260
+ warn(`the publish will very likely be REJECTED by the server after the upload. Bulk image sets must be remote:`);
261
+ log(c.dim(" run `calo-design art push <your image folder>` (login only) — it moves the images to the CDN and"));
262
+ log(c.dim(" generates a manifest module; import that instead of require()ing the files, then delete them."));
263
+ } else if (estimate > EAS_ASSET_LIMIT * 0.7) {
264
+ warn(`heads-up: ~${estimate} assets would ship with this update (${own} yours + ~${DS_BASELINE_ASSETS} design-system). ` +
265
+ `The EAS cap is ${EAS_ASSET_LIMIT}; large image sets belong on the CDN via \`calo-design art push\`.`);
266
+ }
267
+ }
268
+
210
269
  function writeMetroConfig(stage) {
211
270
  const runtimeReal = fs.realpathSync(runtimeDir());
212
271
  fs.writeFileSync(
@@ -236,10 +295,10 @@ function linkNodeModules(stage) {
236
295
 
237
296
  // Overwrite the root _layout with a managed one. Returning to the launcher is now
238
297
  // handled natively by the Mirror shell (shake the device — see withCaloMirrorIos),
239
- // so no visible "back" control is injected; MirrorChrome is a pass-through kept as
240
- // a seam for any future prototype-side chrome. NOTE (v1): a custom root _layout
298
+ // so no visible "back" control is injected; MirrorChrome carries the injected
299
+ // crash reporting (Sentry init + error boundary). NOTE (v1): a custom root _layout
241
300
  // (custom providers/Tabs) is not preserved — we warn when it looks non-standard.
242
- function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel }) {
301
+ function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel, slug, owner }) {
243
302
  const layout = path.join(stagedAppDir, "_layout.tsx");
244
303
  if (fs.existsSync(layout)) {
245
304
  const cur = fs.readFileSync(layout, "utf8");
@@ -264,21 +323,101 @@ export default function RootLayout() {
264
323
  }
265
324
  `
266
325
  );
267
- fs.writeFileSync(
268
- path.join(mirrorChromeDir, "mirror-chrome.tsx"),
269
- `import type { ReactNode } from "react";
326
+ fs.writeFileSync(path.join(mirrorChromeDir, "mirror-chrome.tsx"), mirrorChromeSource({ slug, owner }));
327
+ }
328
+
329
+ // True when the shared runtime can bundle @sentry/react-native. Old runtimes
330
+ // (before the peer was added to calo-cli's PEERS list) can't — inject the plain
331
+ // pass-through chrome there so push keeps working; `calo-design update` fixes it.
332
+ function runtimeHasSentry() {
333
+ return fs.existsSync(path.join(runtimeDir(), "node_modules", "@sentry", "react-native", "package.json"));
334
+ }
335
+
336
+ // The injected chrome. With a DSN + a Sentry-capable runtime it (a) initialises
337
+ // Sentry tagged with the prototype's slug/owner, so every crash is attributable
338
+ // from the feed, and (b) wraps the prototype in an error boundary: a render
339
+ // crash shows a "this prototype crashed, it's been reported" screen instead of
340
+ // killing the shell. Everything is defensive — on an old shell binary without
341
+ // the Sentry NATIVE module, init falls back to JS-only transport (and the
342
+ // try/catch backstops that), so prototypes never crash BECAUSE of reporting.
343
+ function mirrorChromeSource({ slug, owner }) {
344
+ if (!SENTRY_DSN || !runtimeHasSentry()) {
345
+ return `import type { ReactNode } from "react";
270
346
 
271
347
  // Returning to the Mirror launcher is handled NATIVELY by the shell binary:
272
- // shake the device and the shell flips its EAS Update channel back to the
273
- // launcher and relaunches (see calo-design-mirror/plugins/withCaloMirrorIos.js,
274
- // CaloShakeWindow + CaloMirrorBack). That works for every loaded prototype
275
- // without any JS here, so this wrapper no longer renders a "back" control — it
276
- // stays as a pass-through seam for any future prototype-side chrome.
348
+ // shake the device (see calo-design-mirror/plugins/withCaloMirrorIos.js).
349
+ // Crash reporting was NOT injected: no Sentry DSN configured or the shared
350
+ // runtime predates @sentry/react-native (run \`calo-design update\`).
277
351
  export function MirrorChrome({ children }: { children: ReactNode }) {
278
352
  return <>{children}</>;
279
353
  }
280
- `
281
- );
354
+ `;
355
+ }
356
+ return `import { Component, type ErrorInfo, type ReactNode } from "react";
357
+ import { Pressable, ScrollView, Text, View } from "react-native";
358
+
359
+ // Injected by \`calo-design push\` — crash reporting for this prototype.
360
+ // Returning to the Mirror launcher stays NATIVE: shake the device.
361
+ const SENTRY_DSN = ${JSON.stringify(SENTRY_DSN)};
362
+ const PROTOTYPE = ${JSON.stringify(slug)};
363
+ const OWNER = ${JSON.stringify(owner || "")};
364
+
365
+ let Sentry: typeof import("@sentry/react-native") | null = null;
366
+ try {
367
+ Sentry = require("@sentry/react-native");
368
+ Sentry!.init({
369
+ dsn: SENTRY_DSN,
370
+ enableLogs: true,
371
+ enableAutoSessionTracking: false,
372
+ });
373
+ Sentry!.setTags({ surface: "prototype", prototype: PROTOTYPE, owner: OWNER });
374
+ } catch {
375
+ Sentry = null; // reporting must never be the thing that crashes a prototype
376
+ }
377
+
378
+ type CrashState = { error: Error | null };
379
+
380
+ export class MirrorChrome extends Component<{ children: ReactNode }, CrashState> {
381
+ state: CrashState = { error: null };
382
+
383
+ static getDerivedStateFromError(error: Error): CrashState {
384
+ return { error };
385
+ }
386
+
387
+ componentDidCatch(error: Error, info: ErrorInfo): void {
388
+ try {
389
+ Sentry?.captureException(error, {
390
+ contexts: { react: { componentStack: info.componentStack } },
391
+ });
392
+ } catch {}
393
+ }
394
+
395
+ render() {
396
+ const { error } = this.state;
397
+ if (!error) return <>{this.props.children}</>;
398
+ return (
399
+ <View style={{ flex: 1, backgroundColor: "#ffffff", padding: 24, justifyContent: "center", gap: 12 }}>
400
+ <Text style={{ fontSize: 20, fontWeight: "700", color: "#1a1a1a" }}>This prototype crashed</Text>
401
+ <Text style={{ fontSize: 14, color: "#666666" }}>
402
+ {Sentry ? "The error was reported" : "The error couldn't be reported"} — shake the device to return to Mirror.
403
+ </Text>
404
+ <ScrollView style={{ maxHeight: 160, backgroundColor: "#f5f5f5", borderRadius: 8 }} contentContainerStyle={{ padding: 12 }}>
405
+ <Text style={{ fontSize: 12, fontFamily: "Menlo", color: "#c0392b" }}>{String(error.message || error)}</Text>
406
+ </ScrollView>
407
+ <Pressable
408
+ onPress={() => this.setState({ error: null })}
409
+ style={({ pressed }) => ({
410
+ alignSelf: "flex-start", paddingVertical: 10, paddingHorizontal: 16,
411
+ borderRadius: 8, backgroundColor: "#24A170", opacity: pressed ? 0.85 : 1,
412
+ })}
413
+ >
414
+ <Text style={{ color: "#ffffff", fontWeight: "600" }}>Try again</Text>
415
+ </Pressable>
416
+ </View>
417
+ );
418
+ }
419
+ }
420
+ `;
282
421
  }
283
422
 
284
423
  // ---- publish ----------------------------------------------------------------
@@ -318,7 +457,9 @@ function tigrisCreds() {
318
457
  }
319
458
 
320
459
  // Signed PUT to the Tigris bucket (S3 API, path-style: ENDPOINT/BUCKET/key).
321
- async function s3Put(objectKey, body, contentType) {
460
+ // extraHeaders (e.g. cache-control for immutable art) are included in the SigV4
461
+ // signed-header set so the request stays verifiable end to end.
462
+ async function s3Put(objectKey, body, contentType, extraHeaders = {}) {
322
463
  const { key, secret } = tigrisCreds();
323
464
  const region = "auto";
324
465
  const url = new URL(`${ENDPOINT}/${BUCKET}/${objectKey}`);
@@ -328,6 +469,7 @@ async function s3Put(objectKey, body, contentType) {
328
469
  const dateStamp = amzDate.slice(0, 8);
329
470
 
330
471
  const signed = {
472
+ ...Object.fromEntries(Object.entries(extraHeaders).map(([k, v]) => [k.toLowerCase(), String(v)])),
331
473
  "content-type": contentType,
332
474
  host: url.host,
333
475
  "x-amz-content-sha256": payloadHash,
@@ -344,12 +486,11 @@ async function s3Put(objectKey, body, contentType) {
344
486
  const signature = crypto.createHmac("sha256", signingKey).update(stringToSign).digest("hex");
345
487
 
346
488
  // Host is added by the runtime and matches the signed value, so we don't resend it.
489
+ const { host: _host, ...sendHeaders } = signed;
347
490
  const res = await fetch(url, {
348
491
  method: "PUT",
349
492
  headers: {
350
- "content-type": contentType,
351
- "x-amz-content-sha256": payloadHash,
352
- "x-amz-date": amzDate,
493
+ ...sendHeaders,
353
494
  authorization: `AWS4-HMAC-SHA256 Credential=${key}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
354
495
  },
355
496
  body: payload,
@@ -411,7 +552,8 @@ async function cmdPush(args) {
411
552
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), `calo-push-${slug}-`));
412
553
  let keepStage = false;
413
554
  try {
414
- stageProject({ root, stage, slug, title, ad });
555
+ stageProject({ root, stage, slug, title, ad, owner });
556
+ preflightAssetCount(stage);
415
557
 
416
558
  if (dry) {
417
559
  keepStage = true;
@@ -489,4 +631,4 @@ async function cmdPush(args) {
489
631
  }
490
632
  }
491
633
 
492
- module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry };
634
+ module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry, _publicBase: PUBLIC_BASE };
package/bin/share.js CHANGED
@@ -54,8 +54,17 @@ function readPkg(root) {
54
54
  try { return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); } catch { return null; }
55
55
  }
56
56
 
57
+ // npm/npx/wrangler are .cmd shims on Windows, and Node >= 18.20.2 (CVE-2024-27980)
58
+ // refuses to spawn .cmd files without shell:true. With shell:true cmd.exe
59
+ // gets the args space-joined and unescaped, so quote anything with whitespace.
60
+ function winShell(bin, argv) {
61
+ if (process.platform !== "win32") return [bin, argv, false];
62
+ const q = (s) => (/[ \t]/.test(s) ? `"${s}"` : s);
63
+ return [q(bin), argv.map(q), true];
64
+ }
57
65
  function run(bin, argv, opts = {}) {
58
- const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
66
+ const [b, a, shell] = winShell(bin, argv);
67
+ const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
59
68
  if (r.error) throw r.error;
60
69
  if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
61
70
  return r;
@@ -63,7 +72,8 @@ function run(bin, argv, opts = {}) {
63
72
 
64
73
  // Run while capturing combined output (to parse the deploy URL) but still show it.
65
74
  function runCapture(bin, argv, opts = {}) {
66
- const r = spawnSync(bin, argv, { encoding: "utf8", ...opts });
75
+ const [b, a, shell] = winShell(bin, argv);
76
+ const r = spawnSync(b, a, { encoding: "utf8", shell, ...opts });
67
77
  if (r.error) throw r.error;
68
78
  const out = (r.stdout || "") + (r.stderr || "");
69
79
  process.stdout.write(out);
@@ -74,9 +84,9 @@ function runCapture(bin, argv, opts = {}) {
74
84
  // Prefer an installed wrangler (project-local, then global); else npx. Only used by
75
85
  // --direct — the default path needs no wrangler on this machine.
76
86
  function wranglerCmd(root) {
77
- const local = path.join(root, "node_modules", ".bin", "wrangler");
87
+ const local = path.join(root, "node_modules", ".bin", process.platform === "win32" ? "wrangler.cmd" : "wrangler");
78
88
  if (fs.existsSync(local)) return [local, []];
79
- const probe = spawnSync("wrangler", ["--version"], { stdio: "ignore" });
89
+ const probe = spawnSync("wrangler", ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
80
90
  if (!probe.error && probe.status === 0) return ["wrangler", []];
81
91
  return ["npx", ["--yes", "wrangler@latest"]];
82
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.4.7",
3
+ "version": "0.5.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"