@calo-design/cli 0.4.8 → 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
@@ -762,6 +768,8 @@ function help() {
762
768
  ${c.dim("logout")} forget the saved Calo session
763
769
  ${c.dim("push")} publish THIS prototype to the Calo Mirror (login only — no EAS/Tigris creds needed)
764
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")}
765
773
  ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
766
774
  ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
767
775
  ${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
@@ -782,6 +790,8 @@ function help() {
782
790
  else if (cmd === "init") await cmdInit();
783
791
  else if (cmd === "update") await cmdUpdate();
784
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>`?");
785
795
  else if (cmd === "share") await cmdShare(args.slice(1));
786
796
  else if (cmd === "feed") await cmdFeed();
787
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";
@@ -153,7 +159,7 @@ const STAGE_SKIP_DIRS = new Set([
153
159
  "ios", "android", ".tamagui", ".vscode", ".idea",
154
160
  ]);
155
161
 
156
- function stageProject({ root, stage, slug, title, ad }) {
162
+ function stageProject({ root, stage, slug, title, ad, owner }) {
157
163
  // Copy the author's whole project (minus STAGE_SKIP_DIRS). Managed files
158
164
  // (app.json, package.json, tsconfig, babel, metro, node_modules, .gitignore,
159
165
  // the root _layout) are overwritten below, exactly as before.
@@ -206,7 +212,7 @@ function stageProject({ root, stage, slug, title, ad }) {
206
212
 
207
213
  writeMetroConfig(stage);
208
214
  linkNodeModules(stage);
209
- injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel });
215
+ injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel, slug, owner });
210
216
 
211
217
  // EAS Update enumerates project files via git. Give the throwaway staging dir
212
218
  // its own local repo (node_modules ignored). This never leaves the temp dir —
@@ -217,6 +223,49 @@ function stageProject({ root, stage, slug, title, ad }) {
217
223
  run("git", ["-C", stage, "-c", "user.email=mirror@calo.app", "-c", "user.name=Calo Mirror", "commit", "-q", "-m", "mirror push"]);
218
224
  }
219
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
+
220
269
  function writeMetroConfig(stage) {
221
270
  const runtimeReal = fs.realpathSync(runtimeDir());
222
271
  fs.writeFileSync(
@@ -246,10 +295,10 @@ function linkNodeModules(stage) {
246
295
 
247
296
  // Overwrite the root _layout with a managed one. Returning to the launcher is now
248
297
  // handled natively by the Mirror shell (shake the device — see withCaloMirrorIos),
249
- // so no visible "back" control is injected; MirrorChrome is a pass-through kept as
250
- // 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
251
300
  // (custom providers/Tabs) is not preserved — we warn when it looks non-standard.
252
- function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel }) {
301
+ function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel, slug, owner }) {
253
302
  const layout = path.join(stagedAppDir, "_layout.tsx");
254
303
  if (fs.existsSync(layout)) {
255
304
  const cur = fs.readFileSync(layout, "utf8");
@@ -274,21 +323,101 @@ export default function RootLayout() {
274
323
  }
275
324
  `
276
325
  );
277
- fs.writeFileSync(
278
- path.join(mirrorChromeDir, "mirror-chrome.tsx"),
279
- `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";
280
346
 
281
347
  // Returning to the Mirror launcher is handled NATIVELY by the shell binary:
282
- // shake the device and the shell flips its EAS Update channel back to the
283
- // launcher and relaunches (see calo-design-mirror/plugins/withCaloMirrorIos.js,
284
- // CaloShakeWindow + CaloMirrorBack). That works for every loaded prototype
285
- // without any JS here, so this wrapper no longer renders a "back" control — it
286
- // 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\`).
287
351
  export function MirrorChrome({ children }: { children: ReactNode }) {
288
352
  return <>{children}</>;
289
353
  }
290
- `
291
- );
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
+ `;
292
421
  }
293
422
 
294
423
  // ---- publish ----------------------------------------------------------------
@@ -328,7 +457,9 @@ function tigrisCreds() {
328
457
  }
329
458
 
330
459
  // Signed PUT to the Tigris bucket (S3 API, path-style: ENDPOINT/BUCKET/key).
331
- 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 = {}) {
332
463
  const { key, secret } = tigrisCreds();
333
464
  const region = "auto";
334
465
  const url = new URL(`${ENDPOINT}/${BUCKET}/${objectKey}`);
@@ -338,6 +469,7 @@ async function s3Put(objectKey, body, contentType) {
338
469
  const dateStamp = amzDate.slice(0, 8);
339
470
 
340
471
  const signed = {
472
+ ...Object.fromEntries(Object.entries(extraHeaders).map(([k, v]) => [k.toLowerCase(), String(v)])),
341
473
  "content-type": contentType,
342
474
  host: url.host,
343
475
  "x-amz-content-sha256": payloadHash,
@@ -354,12 +486,11 @@ async function s3Put(objectKey, body, contentType) {
354
486
  const signature = crypto.createHmac("sha256", signingKey).update(stringToSign).digest("hex");
355
487
 
356
488
  // Host is added by the runtime and matches the signed value, so we don't resend it.
489
+ const { host: _host, ...sendHeaders } = signed;
357
490
  const res = await fetch(url, {
358
491
  method: "PUT",
359
492
  headers: {
360
- "content-type": contentType,
361
- "x-amz-content-sha256": payloadHash,
362
- "x-amz-date": amzDate,
493
+ ...sendHeaders,
363
494
  authorization: `AWS4-HMAC-SHA256 Credential=${key}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
364
495
  },
365
496
  body: payload,
@@ -421,7 +552,8 @@ async function cmdPush(args) {
421
552
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), `calo-push-${slug}-`));
422
553
  let keepStage = false;
423
554
  try {
424
- stageProject({ root, stage, slug, title, ad });
555
+ stageProject({ root, stage, slug, title, ad, owner });
556
+ preflightAssetCount(stage);
425
557
 
426
558
  if (dry) {
427
559
  keepStage = true;
@@ -499,4 +631,4 @@ async function cmdPush(args) {
499
631
  }
500
632
  }
501
633
 
502
- module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry };
634
+ module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry, _publicBase: PUBLIC_BASE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.4.8",
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"