@calo-design/cli 0.9.3 → 0.9.5

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.
Files changed (2) hide show
  1. package/bin/mirror-push.js +85 -23
  2. package/package.json +1 -1
@@ -382,7 +382,7 @@ export default function RootLayout() {
382
382
  }
383
383
  fs.writeFileSync(path.join(stagedAppDir, "_layout.tsx"), managed);
384
384
  fs.writeFileSync(path.join(mirrorChromeDir, "mirror-chrome.tsx"), mirrorChromeSource({ slug, owner }));
385
- injectNativeIntent(stagedAppDir, slug);
385
+ injectNativeIntent(stagedAppDir, mirrorChromeDir, slug);
386
386
  }
387
387
 
388
388
  // The launcher opens a prototype via `designchef://open/<slug>`. That deep link is
@@ -394,35 +394,34 @@ export default function RootLayout() {
394
394
  // `+native-intent.tsx` is expo-router's hook for rewriting an incoming native URL
395
395
  // before routing: send the launcher's own path to the root and leave every other
396
396
  // deep link untouched, so a prototype's own links keep working.
397
- function injectNativeIntent(stagedAppDir, slug) {
398
- const existing = [".tsx", ".ts", ".jsx", ".js"].find((e) =>
399
- fs.existsSync(path.join(stagedAppDir, "+native-intent" + e))
400
- );
401
- if (existing) {
402
- warn("this prototype has its own +native-intent — leaving it alone. Launcher URLs (/open/<slug>) won't be handled: route them to \"/\" for this prototype's slug and switch expo-updates channels for other slugs.");
403
- return;
404
- }
397
+ function injectNativeIntent(stagedAppDir, mirrorChromeDir, slug) {
398
+ // Shared launcher-URL logic: +native-intent covers COLD starts (the initial
399
+ // URL), and MirrorChrome's Linking listener covers WARM arrivals — on Android
400
+ // a URL delivered to the running app does not reliably reach expo-router's
401
+ // redirectSystemPath, which left designchef://open/<other> showing the
402
+ // currently-loaded prototype. The `switching` latch dedupes when both fire.
405
403
  fs.writeFileSync(
406
- path.join(stagedAppDir, "+native-intent.tsx"),
404
+ path.join(mirrorChromeDir, "mirror-switch.ts"),
407
405
  `// Managed by calo-design push.
408
- // The Mirror launcher opens prototypes with designchef://open/<slug>. Two cases
409
- // reach THIS bundle:
406
+ // The Mirror launcher opens prototypes with designchef://open/<slug>. Cases
407
+ // reaching THIS bundle:
410
408
  // - the URL that opened this prototype survives the expo-updates reload as the
411
409
  // initial URL (/open/<own slug>) — route it to "/" so expo-router doesn't
412
410
  // render "Unmatched Route";
413
- // - a designchef://open/<other> link arrives while this bundle is live — the
414
- // launcher's JS isn't running, so this bundle performs the channel switch
415
- // itself: point expo-updates at the other channel, download, reload.
416
- // ("mirror" is the launcher's own channel, so open/mirror = back to the feed.)
417
- import { Platform } from "react-native";
411
+ // - a designchef://open/<other> link arrives (cold OR while this bundle is
412
+ // live) — the launcher's JS isn't running, so this bundle performs the
413
+ // channel switch itself: point expo-updates at the other channel, download,
414
+ // reload. ("mirror" is the launcher's own channel open/mirror = the feed.)
415
+ import { AppState, Platform } from "react-native";
416
+ import * as ExpoLinking from "expo-linking";
418
417
  import * as Updates from "expo-updates";
419
418
 
420
419
  const PROTOTYPE = ${JSON.stringify(slug)};
421
420
  const UPDATES_URL = ${JSON.stringify(UPDATES_URL)};
422
421
  const RUNTIME_VERSION = ${JSON.stringify(RUNTIME_VERSION)};
423
422
 
424
- // One switch at a time — a second reload racing the first crashes in JSI teardown
425
- // (see calo-design-mirror src/lib/mirror.ts). Reset on failure so retry works.
423
+ // One switch at a time — a second reload racing the first crashes in JSI
424
+ // teardown (see calo-design-mirror src/lib/mirror.ts). Reset on failure.
426
425
  let switching = false;
427
426
  function switchToChannel(channel: string): void {
428
427
  if (switching) return;
@@ -443,7 +442,7 @@ function switchToChannel(channel: string): void {
443
442
  // Download BEFORE reloading; if the channel has nothing this throws and we
444
443
  // stay put instead of reloading into nothing.
445
444
  await Updates.fetchUpdateAsync();
446
- // Defer the reload off the awaited chain (JSI teardown race — see mirror.ts).
445
+ // Defer the reload off the awaited chain (JSI teardown race — mirror.ts).
447
446
  setTimeout(() => { void Updates.reloadAsync().catch(() => { switching = false; }); }, 300);
448
447
  })().catch(() => { switching = false; });
449
448
  }
@@ -466,12 +465,63 @@ function launcherSlug(path: string): string | null {
466
465
  }
467
466
  }
468
467
 
469
- export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
468
+ /** "/" when the URL is the launcher's (starting a switch if it names another prototype), null otherwise. */
469
+ export function handleLauncherPath(path: string): string | null {
470
470
  const slug = launcherSlug(path);
471
- if (slug === null) return path; // not a launcher URL — leave this prototype's own links alone
471
+ if (slug === null) return null;
472
472
  if (slug && slug !== PROTOTYPE) switchToChannel(slug);
473
473
  return "/";
474
474
  }
475
+
476
+ // Warm-link coverage. After Updates.reloadAsync() the React Native
477
+ // Linking 'url' event no longer reaches the reloaded JS context (verified on
478
+ // Android: the launcher's original context gets warm links, a prototype's
479
+ // reloaded context does not). expo-linking has its own native event path and
480
+ // URL store, so listen there — and on every foreground, sweep getLinkingURL()
481
+ // in case the event itself was missed. lastHandled stops the sweep from
482
+ // re-firing on URLs we've already acted on.
483
+ let lastHandled: string | null = null;
484
+
485
+ function handleWarmUrl(url: string | null): void {
486
+ if (!url || url === lastHandled) return;
487
+ lastHandled = url;
488
+ handleLauncherPath(url);
489
+ }
490
+
491
+ export function subscribeLauncherLinks(): { remove: () => void } {
492
+ // Seed with the URL that launched this context so the foreground sweep
493
+ // doesn't replay it (+native-intent already handled the initial URL).
494
+ try { lastHandled = ExpoLinking.getLinkingURL() ?? null; } catch {}
495
+ const linkSub = ExpoLinking.addEventListener("url", ({ url }) => handleWarmUrl(url));
496
+ const stateSub = AppState.addEventListener("change", (state) => {
497
+ if (state !== "active") return;
498
+ try { handleWarmUrl(ExpoLinking.getLinkingURL() ?? null); } catch {}
499
+ });
500
+ return {
501
+ remove: () => {
502
+ linkSub.remove();
503
+ stateSub.remove();
504
+ },
505
+ };
506
+ }
507
+ `
508
+ );
509
+
510
+ const existing = [".tsx", ".ts", ".jsx", ".js"].find((e) =>
511
+ fs.existsSync(path.join(stagedAppDir, "+native-intent" + e))
512
+ );
513
+ if (existing) {
514
+ warn("this prototype has its own +native-intent — leaving it alone. Launcher URLs (/open/<slug>) won't be handled there: delegate to handleLauncherPath from ../mirror-switch.");
515
+ return;
516
+ }
517
+ fs.writeFileSync(
518
+ path.join(stagedAppDir, "+native-intent.tsx"),
519
+ `// Managed by calo-design push — see ../mirror-switch for the rationale.
520
+ import { handleLauncherPath } from "../mirror-switch";
521
+
522
+ export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
523
+ return handleLauncherPath(path) ?? path;
524
+ }
475
525
  `
476
526
  );
477
527
  }
@@ -492,19 +542,26 @@ function runtimeHasSentry() {
492
542
  // try/catch backstops that), so prototypes never crash BECAUSE of reporting.
493
543
  function mirrorChromeSource({ slug, owner }) {
494
544
  if (!SENTRY_DSN || !runtimeHasSentry()) {
495
- return `import type { ReactNode } from "react";
545
+ return `import { useEffect, type ReactNode } from "react";
546
+ import { subscribeLauncherLinks } from "./mirror-switch";
496
547
 
497
548
  // Returning to the Mirror launcher is handled NATIVELY by the shell binary:
498
549
  // shake the device (see calo-design-mirror/plugins/withCaloMirrorIos.js).
499
550
  // Crash reporting was NOT injected: no Sentry DSN configured or the shared
500
551
  // runtime predates @sentry/react-native (run \`calo-design update\`).
501
552
  export function MirrorChrome({ children }: { children: ReactNode }) {
553
+ // Warm launcher links (designchef://open/<other> while this bundle runs).
554
+ useEffect(() => {
555
+ const sub = subscribeLauncherLinks();
556
+ return () => sub.remove();
557
+ }, []);
502
558
  return <>{children}</>;
503
559
  }
504
560
  `;
505
561
  }
506
562
  return `import { Component, type ErrorInfo, type ReactNode } from "react";
507
563
  import { Pressable, ScrollView, Text, View } from "react-native";
564
+ import { subscribeLauncherLinks } from "./mirror-switch";
508
565
 
509
566
  // Injected by \`calo-design push\` — crash reporting for this prototype.
510
567
  // Returning to the Mirror launcher stays NATIVE: shake the device.
@@ -530,6 +587,11 @@ type CrashState = { error: Error | null };
530
587
  export class MirrorChrome extends Component<{ children: ReactNode }, CrashState> {
531
588
  state: CrashState = { error: null };
532
589
 
590
+ // Warm launcher links (designchef://open/<other> while this bundle runs).
591
+ private linkSub: { remove: () => void } | null = null;
592
+ componentDidMount(): void { this.linkSub = subscribeLauncherLinks(); }
593
+ componentWillUnmount(): void { this.linkSub?.remove(); }
594
+
533
595
  static getDerivedStateFromError(error: Error): CrashState {
534
596
  return { error };
535
597
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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"