@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.
- package/bin/art-push.js +281 -0
- package/bin/cli.js +23 -3
- package/bin/login.js +8 -1
- package/bin/mirror-push.js +165 -23
- package/bin/share.js +14 -4
- 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
|
|
@@ -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
|
|
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
|
|
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 };
|
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";
|
|
@@ -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
|
|
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
|
|
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
|
|
240
|
-
//
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
273
|
-
//
|
|
274
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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"
|