@calo-design/cli 0.4.6 → 0.4.8
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/cli.js +53 -16
- package/bin/mirror-push.js +58 -83
- package/bin/share.js +14 -4
- package/package.json +4 -1
package/bin/cli.js
CHANGED
|
@@ -32,7 +32,10 @@ const PEERS = [
|
|
|
32
32
|
// The Mirror loads each prototype as an EAS Update at runtime; prototypes need
|
|
33
33
|
// the expo-updates JS API (the "back to Mirror" chrome `push` injects calls it).
|
|
34
34
|
// The shell binary provides the native module; prototypes ship JS only.
|
|
35
|
-
"expo-updates"
|
|
35
|
+
"expo-updates",
|
|
36
|
+
// Video + bundled-asset support (Mirror shell ships the native side from build 7).
|
|
37
|
+
"expo-video",
|
|
38
|
+
"expo-asset"
|
|
36
39
|
];
|
|
37
40
|
// Install via explicit HTTPS git URLs. Auth is a short-lived GitHub token the Calo
|
|
38
41
|
// broker mints after `calo-design login` — injected into git for the install
|
|
@@ -52,8 +55,17 @@ const ok = (s) => log(`${c.g("✓")} ${s}`);
|
|
|
52
55
|
const warn = (s) => log(`${c.y("!")} ${s}`);
|
|
53
56
|
const tilde = (p) => p.replace(os.homedir(), "~");
|
|
54
57
|
|
|
58
|
+
// npm/npx are .cmd shims on Windows, and Node >= 18.20.2 (CVE-2024-27980)
|
|
59
|
+
// refuses to spawn .cmd files without shell:true. With shell:true cmd.exe
|
|
60
|
+
// gets the args space-joined and unescaped, so quote anything with whitespace.
|
|
61
|
+
function winShell(bin, argv) {
|
|
62
|
+
if (process.platform !== "win32") return [bin, argv, false];
|
|
63
|
+
const q = (s) => (/[ \t]/.test(s) ? `"${s}"` : s);
|
|
64
|
+
return [q(bin), argv.map(q), true];
|
|
65
|
+
}
|
|
55
66
|
function run(bin, argv, opts = {}) {
|
|
56
|
-
const
|
|
67
|
+
const [b, a, shell] = winShell(bin, argv);
|
|
68
|
+
const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
|
|
57
69
|
if (r.error) throw r.error;
|
|
58
70
|
if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
|
|
59
71
|
return r;
|
|
@@ -65,7 +77,8 @@ function sleep(sec) {
|
|
|
65
77
|
// races / rate limits) and recover on retry. Retry before giving up.
|
|
66
78
|
function runWithRetry(bin, argv, tries = 3, opts = {}) {
|
|
67
79
|
for (let i = 1; i <= tries; i++) {
|
|
68
|
-
const
|
|
80
|
+
const [b, a, shell] = winShell(bin, argv);
|
|
81
|
+
const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
|
|
69
82
|
if (!r.error && r.status === 0) return r;
|
|
70
83
|
if (i < tries) { warn(`install hiccup — retrying (${i}/${tries - 1})…`); sleep(3); }
|
|
71
84
|
}
|
|
@@ -185,6 +198,17 @@ function staleCaloDeps(rt, env) {
|
|
|
185
198
|
return stale;
|
|
186
199
|
}
|
|
187
200
|
|
|
201
|
+
// Peers added to PEERS after a runtime was built (e.g. expo-video) never arrive
|
|
202
|
+
// via the @calo git-dep refresh, so detect them from the runtime's package.json.
|
|
203
|
+
function missingPeers(rt) {
|
|
204
|
+
try {
|
|
205
|
+
const deps = JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8")).dependencies || {};
|
|
206
|
+
return PEERS.filter((p) => !deps[p]);
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
188
212
|
async function ensureRuntime({ force } = {}) {
|
|
189
213
|
const rt = runtimeDir();
|
|
190
214
|
if (!force && runtimeExists()) {
|
|
@@ -195,22 +219,35 @@ async function ensureRuntime({ force } = {}) {
|
|
|
195
219
|
env = gitTokenEnv(await githubToken());
|
|
196
220
|
stale = staleCaloDeps(rt, env);
|
|
197
221
|
} catch {} // offline / broker hiccup — keep the existing runtime, don't block init
|
|
198
|
-
|
|
222
|
+
const missing = missingPeers(rt);
|
|
223
|
+
if (!stale.length && !missing.length) {
|
|
199
224
|
ok(`shared runtime ready (${tilde(rt)})`);
|
|
200
225
|
return;
|
|
201
226
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
227
|
+
if (stale.length) {
|
|
228
|
+
log(c.b(`\n[runtime] ${stale.join(" + ")} behind latest — refreshing the shared runtime`));
|
|
229
|
+
try {
|
|
230
|
+
// Non-destructive: `npm install` of the git specs re-resolves HEAD and updates
|
|
231
|
+
// node_modules in place; a failure aborts cleanly and leaves the runtime working.
|
|
232
|
+
runWithRetry("npm", ["install", ...PKG_SPECS, "--legacy-peer-deps"], 3, { cwd: rt, env });
|
|
233
|
+
let manifest = { pkgSpecs: PKG_SPECS, peers: PEERS };
|
|
234
|
+
try { manifest = { ...JSON.parse(fs.readFileSync(runtimeManifestPath(), "utf8")), ...manifest }; } catch {}
|
|
235
|
+
manifest.refreshedAt = new Date().toISOString();
|
|
236
|
+
fs.writeFileSync(runtimeManifestPath(), JSON.stringify(manifest, null, 2) + "\n");
|
|
237
|
+
ok("shared runtime refreshed — every linked prototype now uses the latest Calo stack");
|
|
238
|
+
} catch (e) {
|
|
239
|
+
warn(`couldn't refresh the runtime (${e.message}) — continuing with the existing install; \`calo-design update\` retries this.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (missing.length) {
|
|
243
|
+
log(c.b(`\n[runtime] adding shared deps: ${missing.join(", ")}`));
|
|
244
|
+
try {
|
|
245
|
+
// `expo install` pins the version matching the runtime's Expo SDK.
|
|
246
|
+
run("npx", ["expo", "install", ...missing], { cwd: rt });
|
|
247
|
+
ok(`shared runtime now provides ${missing.join(", ")}`);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
warn(`couldn't add ${missing.join(", ")} (${e.message}) — \`calo-design update\` retries this.`);
|
|
250
|
+
}
|
|
214
251
|
}
|
|
215
252
|
return;
|
|
216
253
|
}
|
package/bin/mirror-push.js
CHANGED
|
@@ -50,14 +50,24 @@ const flag = (args, name, def) => {
|
|
|
50
50
|
};
|
|
51
51
|
const has = (args, name) => args.includes(name);
|
|
52
52
|
|
|
53
|
+
// npm/npx/eas are .cmd shims on Windows, and Node >= 18.20.2 (CVE-2024-27980)
|
|
54
|
+
// refuses to spawn .cmd files without shell:true. With shell:true cmd.exe
|
|
55
|
+
// gets the args space-joined and unescaped, so quote anything with whitespace.
|
|
56
|
+
function winShell(bin, argv) {
|
|
57
|
+
if (process.platform !== "win32") return [bin, argv, false];
|
|
58
|
+
const q = (s) => (/[ \t]/.test(s) ? `"${s}"` : s);
|
|
59
|
+
return [q(bin), argv.map(q), true];
|
|
60
|
+
}
|
|
53
61
|
function run(bin, argv, opts = {}) {
|
|
54
|
-
const
|
|
62
|
+
const [b, a, shell] = winShell(bin, argv);
|
|
63
|
+
const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
|
|
55
64
|
if (r.error) throw r.error;
|
|
56
65
|
if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
|
|
57
66
|
return r;
|
|
58
67
|
}
|
|
59
68
|
function capture(bin, argv, opts = {}) {
|
|
60
|
-
const
|
|
69
|
+
const [b, a, shell] = winShell(bin, argv);
|
|
70
|
+
const r = spawnSync(b, a, { encoding: "utf8", shell, ...opts });
|
|
61
71
|
return { status: r.status == null ? 1 : r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -134,13 +144,27 @@ function ensureBabelConfig(dir, fallbackFrom) {
|
|
|
134
144
|
fs.writeFileSync(dst, 'module.exports = (api) => {\n api.cache(true);\n return { presets: ["babel-preset-expo"] };\n};\n');
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
// Never staged: installed/build/native output and editor state. Everything else
|
|
148
|
+
// is the author's source and must be copied verbatim — prototypes keep code in
|
|
149
|
+
// sibling folders (src/components, src/lib, public/, …) that the routes import
|
|
150
|
+
// via the `@/*` tsconfig alias, so staging only src/app breaks the export.
|
|
151
|
+
const STAGE_SKIP_DIRS = new Set([
|
|
152
|
+
"node_modules", ".git", ".expo", ".expo-shared", "dist", "build",
|
|
153
|
+
"ios", "android", ".tamagui", ".vscode", ".idea",
|
|
154
|
+
]);
|
|
155
|
+
|
|
137
156
|
function stageProject({ root, stage, slug, title, ad }) {
|
|
138
|
-
// Copy the author's
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
fs.cpSync(
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
// Copy the author's whole project (minus STAGE_SKIP_DIRS). Managed files
|
|
158
|
+
// (app.json, package.json, tsconfig, babel, metro, node_modules, .gitignore,
|
|
159
|
+
// the root _layout) are overwritten below, exactly as before.
|
|
160
|
+
fs.cpSync(root, stage, {
|
|
161
|
+
recursive: true,
|
|
162
|
+
filter: (src) => {
|
|
163
|
+
const name = path.basename(src);
|
|
164
|
+
if (name === ".DS_Store" || name.endsWith(".log")) return false;
|
|
165
|
+
return !STAGE_SKIP_DIRS.has(name);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
144
168
|
|
|
145
169
|
const usesSrc = path.basename(path.dirname(ad)) === "src";
|
|
146
170
|
const stagedAppDir = path.join(stage, usesSrc ? "src" : "", "app");
|
|
@@ -220,9 +244,11 @@ function linkNodeModules(stage) {
|
|
|
220
244
|
fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
221
245
|
}
|
|
222
246
|
|
|
223
|
-
// Overwrite the root _layout with a managed one
|
|
224
|
-
//
|
|
225
|
-
//
|
|
247
|
+
// Overwrite the root _layout with a managed one. Returning to the launcher is now
|
|
248
|
+
// 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
|
|
251
|
+
// (custom providers/Tabs) is not preserved — we warn when it looks non-standard.
|
|
226
252
|
function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel }) {
|
|
227
253
|
const layout = path.join(stagedAppDir, "_layout.tsx");
|
|
228
254
|
if (fs.existsSync(layout)) {
|
|
@@ -251,80 +277,15 @@ export default function RootLayout() {
|
|
|
251
277
|
fs.writeFileSync(
|
|
252
278
|
path.join(mirrorChromeDir, "mirror-chrome.tsx"),
|
|
253
279
|
`import type { ReactNode } from "react";
|
|
254
|
-
import { useState } from "react";
|
|
255
|
-
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
|
256
|
-
import * as Updates from "expo-updates";
|
|
257
|
-
|
|
258
|
-
// One return at a time. A second tap before the reload lands fires a second
|
|
259
|
-
// reloadAsync(), racing two runtime teardowns -> SIGBUS in ExpoModulesJSI. The
|
|
260
|
-
// latch (plus the disabled button) drops extra taps; a failure clears it to retry.
|
|
261
|
-
let returning = false;
|
|
262
|
-
|
|
263
|
-
// Injected by \`calo-design push\`. Flips the binary back to the Mirror launcher.
|
|
264
|
-
async function backToMirror() {
|
|
265
|
-
if (returning || !Updates.isEnabled) return;
|
|
266
|
-
returning = true;
|
|
267
|
-
try {
|
|
268
|
-
await Updates.setUpdateURLAndRequestHeadersOverride({
|
|
269
|
-
updateUrl: ${JSON.stringify(UPDATES_URL)},
|
|
270
|
-
requestHeaders: {
|
|
271
|
-
"expo-channel-name": ${JSON.stringify(LAUNCHER_CHANNEL)},
|
|
272
|
-
"expo-runtime-version": Updates.runtimeVersion ?? ${JSON.stringify(RUNTIME_VERSION)},
|
|
273
|
-
"expo-platform": Platform.OS,
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
// EAS requires channel + runtime-version + platform (400 otherwise); the
|
|
277
|
-
// override replaces headers, so send all three, then download.
|
|
278
|
-
await Updates.fetchUpdateAsync();
|
|
279
|
-
} catch {
|
|
280
|
-
returning = false;
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// Defer the reload to a fresh macrotask. Reloading inside the awaited chain
|
|
284
|
-
// races the fetch's JSI promise + the expo-updates reaper teardown -> SIGBUS
|
|
285
|
-
// in ExpoModulesJSI. Let the chain unwind + reaper settle, then reload.
|
|
286
|
-
// reloadAsync never resolves (the app reloads), so it is not awaited.
|
|
287
|
-
setTimeout(() => {
|
|
288
|
-
void Updates.reloadAsync().catch(() => {
|
|
289
|
-
returning = false;
|
|
290
|
-
});
|
|
291
|
-
}, 300);
|
|
292
|
-
}
|
|
293
280
|
|
|
281
|
+
// 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.
|
|
294
287
|
export function MirrorChrome({ children }: { children: ReactNode }) {
|
|
295
|
-
|
|
296
|
-
return (
|
|
297
|
-
<View style={{ flex: 1 }}>
|
|
298
|
-
{children}
|
|
299
|
-
<Pressable
|
|
300
|
-
accessibilityLabel="Back to Mirror"
|
|
301
|
-
accessibilityRole="button"
|
|
302
|
-
disabled={going}
|
|
303
|
-
onPress={() => {
|
|
304
|
-
setGoing(true);
|
|
305
|
-
void backToMirror();
|
|
306
|
-
}}
|
|
307
|
-
hitSlop={8}
|
|
308
|
-
style={({ pressed }) => ({
|
|
309
|
-
position: "absolute",
|
|
310
|
-
left: 16,
|
|
311
|
-
bottom: 36,
|
|
312
|
-
flexDirection: "row",
|
|
313
|
-
alignItems: "center",
|
|
314
|
-
gap: 8,
|
|
315
|
-
backgroundColor: "#208AEF",
|
|
316
|
-
paddingHorizontal: 16,
|
|
317
|
-
paddingVertical: 10,
|
|
318
|
-
borderRadius: 999,
|
|
319
|
-
opacity: going ? 0.85 : pressed ? 0.7 : 1,
|
|
320
|
-
transform: [{ scale: pressed ? 0.96 : 1 }],
|
|
321
|
-
})}
|
|
322
|
-
>
|
|
323
|
-
{going ? <ActivityIndicator size="small" color="#fff" /> : null}
|
|
324
|
-
<Text style={{ color: "#fff", fontWeight: "600" }}>{going ? "Returning…" : "← Mirror"}</Text>
|
|
325
|
-
</Pressable>
|
|
326
|
-
</View>
|
|
327
|
-
);
|
|
288
|
+
return <>{children}</>;
|
|
328
289
|
}
|
|
329
290
|
`
|
|
330
291
|
);
|
|
@@ -519,6 +480,20 @@ async function cmdPush(args) {
|
|
|
519
480
|
}
|
|
520
481
|
|
|
521
482
|
log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and tap Refresh — your prototype is at the top.");
|
|
483
|
+
|
|
484
|
+
// Share target: the deep link the Mirror scanner (and iOS camera) opens.
|
|
485
|
+
// Print it + a scannable QR so a teammate can jump straight in.
|
|
486
|
+
const deepLink = `designchef://open/${slug}`;
|
|
487
|
+
log(c.b("\n📱 Share this prototype") + c.dim(" (teammate needs Calo Mirror installed)"));
|
|
488
|
+
log(c.dim(" Scan the QR with Calo Mirror — or share the link:"));
|
|
489
|
+
log(" " + c.b(deepLink) + "\n");
|
|
490
|
+
try {
|
|
491
|
+
require("qrcode-terminal").generate(deepLink, { small: true }, (qr) =>
|
|
492
|
+
log(qr.replace(/^/gm, " ")),
|
|
493
|
+
);
|
|
494
|
+
} catch {
|
|
495
|
+
/* qrcode-terminal missing — the link above is enough to share */
|
|
496
|
+
}
|
|
522
497
|
} finally {
|
|
523
498
|
if (!keepStage) fs.rmSync(stage, { recursive: true, force: true });
|
|
524
499
|
}
|
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.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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"
|
|
@@ -14,5 +14,8 @@
|
|
|
14
14
|
"license": "UNLICENSED",
|
|
15
15
|
"engines": {
|
|
16
16
|
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"qrcode-terminal": "^0.12.0"
|
|
17
20
|
}
|
|
18
21
|
}
|