@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.
- package/bin/art-push.js +281 -0
- package/bin/cli.js +11 -1
- package/bin/login.js +8 -1
- package/bin/mirror-push.js +153 -21
- package/package.json +1 -1
package/bin/art-push.js
ADDED
|
@@ -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 };
|
package/bin/mirror-push.js
CHANGED
|
@@ -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
|
|
250
|
-
//
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|