@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 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 r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
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 r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
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
- if (!stale.length) {
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
- log(c.b(`\n[runtime] ${stale.join(" + ")} behind latest — refreshing the shared runtime`));
203
- try {
204
- // Non-destructive: `npm install` of the git specs re-resolves HEAD and updates
205
- // node_modules in place; a failure aborts cleanly and leaves the runtime working.
206
- runWithRetry("npm", ["install", ...PKG_SPECS, "--legacy-peer-deps"], 3, { cwd: rt, env });
207
- let manifest = { pkgSpecs: PKG_SPECS, peers: PEERS };
208
- try { manifest = { ...JSON.parse(fs.readFileSync(runtimeManifestPath(), "utf8")), ...manifest }; } catch {}
209
- manifest.refreshedAt = new Date().toISOString();
210
- fs.writeFileSync(runtimeManifestPath(), JSON.stringify(manifest, null, 2) + "\n");
211
- ok("shared runtime refreshed every linked prototype now uses the latest Calo stack");
212
- } catch (e) {
213
- warn(`couldn't refresh the runtime (${e.message}) — continuing with the existing install; \`calo-design update\` retries this.`);
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
  }
@@ -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 r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
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 r = spawnSync(bin, argv, { encoding: "utf8", ...opts });
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 routes + assets verbatim.
139
- const stageSrcParent = path.join(stage, path.relative(root, path.dirname(ad)));
140
- fs.mkdirSync(stageSrcParent, { recursive: true });
141
- fs.cpSync(ad, path.join(stageSrcParent, path.basename(ad)), { recursive: true });
142
- const assets = path.join(root, "assets");
143
- if (fs.existsSync(assets)) fs.cpSync(assets, path.join(stage, "assets"), { recursive: true });
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 that wraps the app in a floating
224
- // "← Mirror" control. NOTE (v1): a custom root _layout (custom providers/Tabs) is
225
- // not preserved we warn when the layout looks non-standard.
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
- const [going, setGoing] = useState(false);
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 r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
66
+ const [b, a, shell] = winShell(bin, argv);
67
+ const r = spawnSync(b, a, { stdio: "inherit", shell, ...opts });
59
68
  if (r.error) throw r.error;
60
69
  if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
61
70
  return r;
@@ -63,7 +72,8 @@ function run(bin, argv, opts = {}) {
63
72
 
64
73
  // Run while capturing combined output (to parse the deploy URL) but still show it.
65
74
  function runCapture(bin, argv, opts = {}) {
66
- const r = spawnSync(bin, argv, { encoding: "utf8", ...opts });
75
+ const [b, a, shell] = winShell(bin, argv);
76
+ const r = spawnSync(b, a, { encoding: "utf8", shell, ...opts });
67
77
  if (r.error) throw r.error;
68
78
  const out = (r.stdout || "") + (r.stderr || "");
69
79
  process.stdout.write(out);
@@ -74,9 +84,9 @@ function runCapture(bin, argv, opts = {}) {
74
84
  // Prefer an installed wrangler (project-local, then global); else npx. Only used by
75
85
  // --direct — the default path needs no wrangler on this machine.
76
86
  function wranglerCmd(root) {
77
- const local = path.join(root, "node_modules", ".bin", "wrangler");
87
+ const local = path.join(root, "node_modules", ".bin", process.platform === "win32" ? "wrangler.cmd" : "wrangler");
78
88
  if (fs.existsSync(local)) return [local, []];
79
- const probe = spawnSync("wrangler", ["--version"], { stdio: "ignore" });
89
+ const probe = spawnSync("wrangler", ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
80
90
  if (!probe.error && probe.status === 0) return ["wrangler", []];
81
91
  return ["npx", ["--yes", "wrangler@latest"]];
82
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.4.6",
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
  }