@calo-design/cli 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -27,6 +27,27 @@ npx @calo-design/cli logout # forget the saved session
27
27
  The login session is stored at `~/.designchef/session.json` (chmod 600) and refreshes
28
28
  automatically. Override the broker with `CALO_BROKER_URL` (used for local dev).
29
29
 
30
+ ## Publish
31
+
32
+ Share a prototype with the team via the [Calo Mirror](https://github.com/Calo-Design/calo-design-mirror).
33
+ Run from **inside a standalone prototype folder** (a plain Expo app with a `src/app/` route dir):
34
+
35
+ ```bash
36
+ calo-design push --slug <slug> --title "<Title>" --owner "<name>" --message "<msg>"
37
+ ```
38
+
39
+ **No EAS or Tigris credentials needed** — just `calo-design login`. The broker vends the
40
+ Expo token for the `eas update` and performs the registry write server-side. It does two things:
41
+
42
+ - `eas update --branch <slug>` — publishes the prototype's JS bundle to its own EAS Update
43
+ channel in the shared project (creating the channel on first push; latest wins on re-push).
44
+ Bundling runs locally; auth uses the broker-vended Expo token.
45
+ - Hands the registry entry (and `screenshot.png` if present) to the broker, which upserts the
46
+ Tigris `index.json` feed so the prototype shows up in the Mirror's browsable list.
47
+
48
+ Use `--dry-run` to stage a managed copy and print what would happen, without publishing.
49
+ `--direct` is the legacy path that publishes from your own local EAS + Tigris creds (maintainer/debug).
50
+
30
51
  Fonts ship with `@calo/design-system`. Load them in your root layout:
31
52
 
32
53
  ```tsx
@@ -40,6 +61,7 @@ const [fontsLoaded] = useFonts(caloFonts);
40
61
  ```
41
62
  npx @calo-design/cli login → broker verifies @calo.app + emails OTP → session JWT (~/.designchef)
42
63
  npx @calo-design/cli init → broker mints a 1h read-only GitHub token → private npm installs
64
+ calo-design push → broker vends the Expo token + writes the Mirror registry server-side
43
65
  ```
44
66
 
45
67
  The broker (`../calo-broker`) is the only place the GitHub / Expo / Tigris secrets
package/bin/cli.js CHANGED
@@ -446,8 +446,8 @@ function help() {
446
446
  ${c.dim("init --skip-packages")} skill only
447
447
  ${c.dim("update")} refresh the skill + re-pin the shared runtime (all linked prototypes float to latest)
448
448
  ${c.dim("logout")} forget the saved Calo session
449
- ${c.dim("push")} publish THIS prototype to the Calo Mirror (eas update + registry)
450
- ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run")}
449
+ ${c.dim("push")} publish THIS prototype to the Calo Mirror (login only no EAS/Tigris creds needed)
450
+ ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run --direct")}
451
451
 
452
452
  Login is required once before init; the session refreshes automatically.
453
453
  Prototypes share one install at ${c.dim(tilde(runtimeDir()))} (override with DESIGNCHEF_HOME).
package/bin/login.js CHANGED
@@ -147,4 +147,19 @@ async function githubToken() {
147
147
  return r.token;
148
148
  }
149
149
 
150
- module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, BROKER, loadSession };
150
+ // Shared Expo token for `eas update` (broker-vended; see /v1/eas-token). Lets a
151
+ // logged-in user publish to the shared Mirror project without their own Expo auth.
152
+ async function easToken() {
153
+ const session = await ensureSession();
154
+ const r = await api("/v1/eas-token", {}, session);
155
+ return r.token;
156
+ }
157
+
158
+ // Hand a built registry entry (+ optional base64 screenshot) to the broker, which
159
+ // performs the Tigris writes server-side. Returns the broker's JSON result.
160
+ async function publishToMirror(payload) {
161
+ const session = await ensureSession();
162
+ return api("/v1/publish", payload, session);
163
+ }
164
+
165
+ module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishToMirror, BROKER, loadSession };
@@ -18,6 +18,7 @@ const crypto = require("node:crypto");
18
18
  const fs = require("node:fs");
19
19
  const os = require("node:os");
20
20
  const path = require("node:path");
21
+ const { ensureLoggedIn, easToken, publishToMirror } = require("./login");
21
22
 
22
23
  // ---- shared contract with the Mirror shell (calo-design-mirror) -------------
23
24
  const SHARED_PROJECT_ID = "290a759f-427c-432e-9ab5-dab98310e66b";
@@ -70,8 +71,11 @@ function easPrefix() {
70
71
  }
71
72
  return _easPrefix;
72
73
  }
73
- const easRun = (argv, opts = {}) => { const [b, ...p] = easPrefix(); return run(b, [...p, ...argv], opts); };
74
- const easCapture = (argv, opts = {}) => { const [b, ...p] = easPrefix(); return capture(b, [...p, ...argv], opts); };
74
+ // Env for the eas subprocess. The default (brokered) push sets this to carry the
75
+ // broker-vended EXPO_TOKEN, so `eas` authenticates without a local `eas login`.
76
+ let easEnv = process.env;
77
+ const easRun = (argv, opts = {}) => { const [b, ...p] = easPrefix(); return run(b, [...p, ...argv], { env: easEnv, ...opts }); };
78
+ const easCapture = (argv, opts = {}) => { const [b, ...p] = easPrefix(); return capture(b, [...p, ...argv], { env: easEnv, ...opts }); };
75
79
 
76
80
  function runtimeDir() {
77
81
  const home = process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
@@ -419,6 +423,7 @@ async function upsertRegistry(entry) {
419
423
  async function cmdPush(args) {
420
424
  const root = process.cwd();
421
425
  const dry = has(args, "--dry-run");
426
+ const direct = has(args, "--direct"); // legacy: publish from this machine's own EAS + Tigris creds
422
427
 
423
428
  const pkgPath = path.join(root, "package.json");
424
429
  if (!fs.existsSync(pkgPath)) throw new Error("no package.json here — run `calo-design push` from inside a prototype folder.");
@@ -446,22 +451,57 @@ async function cmdPush(args) {
446
451
  keepStage = true;
447
452
  ok(`dry-run — staged a managed copy at:\n ${stage}`);
448
453
  log(c.dim(" would run: eas channel:create " + slug + " ; eas update --branch " + slug));
449
- log(c.dim(` would write: ${PUBLIC_BASE}/index.json + screenshots/${slug}.png` + (screenshot ? "" : " (no screenshot found)")));
454
+ log(c.dim(direct
455
+ ? ` would write (direct, local Tigris creds): ${PUBLIC_BASE}/index.json + screenshots/${slug}.png`
456
+ : ` would publish via broker: registry entry${screenshot ? " + screenshot" : ""} → ${PUBLIC_BASE}/index.json`));
457
+ if (!screenshot) log(c.dim(" (no screenshot found)"));
450
458
  return;
451
459
  }
452
460
 
453
- ensureEas();
454
- ensureChannel(stage, slug);
455
- easRun(["update", "--branch", slug, "--message", message, "--environment", "production", "--non-interactive"], { cwd: stage });
456
- ok(`published EAS update → channel ${c.b(slug)}`);
457
-
458
- const screenshotUrl = screenshot ? await uploadScreenshot(slug, screenshot) : undefined;
459
- await upsertRegistry({
460
- slug, title, owner, description,
461
- channel: slug, runtimeVersion: RUNTIME_VERSION,
462
- screenshotUrl, updatedAt: new Date().toISOString(),
463
- });
464
- ok("registry updated");
461
+ if (direct) {
462
+ // Legacy path: publish straight from this machine using local Expo auth +
463
+ // local Tigris creds (CALO_MIRROR_KEY/SECRET). For the maintainer / debugging.
464
+ ensureEas();
465
+ ensureChannel(stage, slug);
466
+ easRun(["update", "--branch", slug, "--message", message, "--environment", "production", "--non-interactive"], { cwd: stage });
467
+ ok(`published EAS update → channel ${c.b(slug)}`);
468
+ const screenshotUrl = screenshot ? await uploadScreenshot(slug, screenshot) : undefined;
469
+ await upsertRegistry({
470
+ slug, title, owner, description,
471
+ channel: slug, runtimeVersion: RUNTIME_VERSION,
472
+ screenshotUrl, updatedAt: new Date().toISOString(),
473
+ });
474
+ ok("registry updated (direct)");
475
+ } else {
476
+ // Default: no local secrets. Log in to the broker, which vends the Expo token
477
+ // for `eas update` and performs the Tigris registry write server-side.
478
+ await ensureLoggedIn();
479
+ let token;
480
+ try {
481
+ token = await easToken();
482
+ } catch (e) {
483
+ throw new Error(`${e.message}\n The broker may not be configured for publishing yet — or use \`--direct\` with local EAS + Tigris creds.`);
484
+ }
485
+ easEnv = { ...process.env, EXPO_TOKEN: token };
486
+ ensureChannel(stage, slug);
487
+ easRun(["update", "--branch", slug, "--message", message, "--environment", "production", "--non-interactive"], { cwd: stage });
488
+ ok(`published EAS update → channel ${c.b(slug)}`);
489
+ const screenshotBase64 = screenshot ? fs.readFileSync(screenshot).toString("base64") : undefined;
490
+ let res;
491
+ try {
492
+ res = await publishToMirror({
493
+ entry: { slug, title, owner, description, runtimeVersion: RUNTIME_VERSION },
494
+ screenshotBase64,
495
+ });
496
+ } catch (e) {
497
+ // eas update already published the bundle; only the registry listing failed.
498
+ throw new Error(
499
+ `EAS update for "${slug}" published, but the Mirror registry write failed: ${e.message}\n` +
500
+ ` The bundle is live on its channel — it just isn't listed yet. Re-run \`calo-design push\` to retry the listing (or \`--direct\` with local Tigris creds).`
501
+ );
502
+ }
503
+ ok(`registry updated via broker${res && typeof res.count === "number" ? ` (${res.count} prototypes live)` : ""}`);
504
+ }
465
505
 
466
506
  log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and tap Refresh — your prototype is at the top.");
467
507
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": { "calo-design": "bin/cli.js" },
6
6
  "files": ["bin"],
@@ -1,427 +0,0 @@
1
- "use strict";
2
-
3
- /**
4
- * `calo-design push` (Module Federation model).
5
- *
6
- * Compiles an author's plain Expo Router prototype into a self-contained
7
- * Module Federation REMOTE container and uploads it to the Fly Tigris registry.
8
- * The Calo Mirror shell loads each container at runtime, in its own error
9
- * boundary, so one prototype crashing can't take down the app.
10
- *
11
- * The author never touches Re.Pack: this command generates the federation entry
12
- * (renders the Expo Router app via ExpoRoot + require.context), the rspack
13
- * config, and the babel/CLI config, stages them against the shared runtime, and
14
- * builds. No git, no EAS — just build + upload + upsert the registry index.
15
- */
16
-
17
- const { spawnSync } = require("node:child_process");
18
- const fs = require("node:fs");
19
- const os = require("node:os");
20
- const path = require("node:path");
21
-
22
- const { _s3Put } = require("./mirror-push.js"); // proven SigV4 PUT (Tigris, S3 API)
23
-
24
- const PUBLIC_BASE = process.env.CALO_MIRROR_PUBLIC_BASE || "https://calo-design-mirror.fly.storage.tigris.dev";
25
-
26
- const c = {
27
- dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`,
28
- g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`,
29
- };
30
- const log = (s = "") => console.log(s);
31
- const ok = (s) => log(`${c.g("✓")} ${s}`);
32
- const warn = (s) => log(`${c.y("!")} ${s}`);
33
-
34
- const flag = (args, name, def) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? args[i + 1] : def; };
35
- const has = (args, name) => args.includes(name);
36
-
37
- function run(bin, argv, opts = {}) {
38
- const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
39
- if (r.error) throw r.error;
40
- if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
41
- return r;
42
- }
43
-
44
- function runtimeDir() {
45
- const home = process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
46
- return path.join(home, "runtime");
47
- }
48
-
49
- // The complete set of shared-runtime packages the host provides and the prototype
50
- // consumes (one instance each → no duplicate native-view registration, one
51
- // expo-router store). react/react-native are shared separately; web-only and
52
- // expo-updates (native, not in the host) are excluded.
53
- const EXCLUDE_SHARED = new Set(["react", "react-native", "react-native-web", "react-dom", "expo", "expo-updates", "@expo/ui"]);
54
- // Navigation modules carry React contexts + the expo-router store. Module
55
- // Federation evaluates a SHARED module twice (once in the host bundle, once when
56
- // the host's container is loaded by a remote) → two router stores / two nav
57
- // contexts → the navigator renders blank. Keep the whole navigation stack OUT of
58
- // the shared set so each prototype bundles its OWN single instance (single eval →
59
- // one store → it paints). Native libs (screens, safe-area, reanimated, svg) stay
60
- // shared — they MUST be single instances or their native views register twice.
61
- const PROTOTYPE_OWNED_NAV = new Set([
62
- "expo-router",
63
- "@react-navigation/native",
64
- "@react-navigation/native-stack",
65
- "@react-navigation/core",
66
- "@react-navigation/elements",
67
- "@react-navigation/routers",
68
- "@react-navigation/bottom-tabs",
69
- "@react-navigation/stack",
70
- ]);
71
- function runtimeSharedDeps() {
72
- const rt = JSON.parse(fs.readFileSync(path.join(runtimeDir(), "package.json"), "utf8")).dependencies || {};
73
- return [...new Set([...Object.keys(rt).filter((d) => !EXCLUDE_SHARED.has(d) && !PROTOTYPE_OWNED_NAV.has(d)), "expo-modules-core"])];
74
- }
75
- const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "prototype";
76
- const titleize = (s) => String(s).replace(/[-_]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase());
77
- // MF scope must be a valid JS identifier (no hyphens, no leading digit).
78
- const scopeOf = (slug) => "proto_" + slug.replace(/[^a-z0-9]+/gi, "_");
79
-
80
- function appDir(root) {
81
- for (const d of [path.join(root, "src", "app"), path.join(root, "app")]) if (fs.existsSync(d)) return d;
82
- return null;
83
- }
84
- function gitName() {
85
- const r = spawnSync("git", ["config", "user.name"], { encoding: "utf8" });
86
- return r.status === 0 ? (r.stdout || "").trim() : "";
87
- }
88
- function findScreenshot(root, explicit) {
89
- if (explicit) return explicit;
90
- for (const p of ["screenshot.png", path.join("assets", "screenshot.png"), path.join("assets", "preview.png")]) {
91
- if (fs.existsSync(path.join(root, p))) return path.join(root, p);
92
- }
93
- return undefined;
94
- }
95
-
96
- // ---- generated files (the federation machinery the author never sees) -------
97
-
98
- // Renders the author's Expo Router app as a single federated component. ExpoRoot
99
- // brings its own SafeAreaProvider + NavigationContainer; require.context feeds it
100
- // the route files (same mechanism Metro uses, made explicit for rspack).
101
- const MIRROR_ENTRY = `import 'react-native-gesture-handler';
102
- import React from 'react';
103
- import { Platform, View, Text } from 'react-native';
104
- import PlatformDeep from 'react-native/Libraries/Utilities/Platform';
105
- import { ExpoRoot } from 'expo-router';
106
-
107
- // MF gives this container a react-native whose top-level Platform.OS is null
108
- // (the deep Platform.ios.js is 'ios'). expo-router reads \`import { Platform }
109
- // from 'react-native'\` → null → blank navigator. Force the consumed Platform
110
- // object's OS to 'ios' before expo-router evaluates.
111
- if (typeof globalThis !== 'undefined') { (globalThis as any).__MAEVAL = (((globalThis as any).__MAEVAL) || 0) + 1; }
112
- const __pfix: any = {};
113
- {
114
- __pfix.evals = String((globalThis as any).__MAEVAL);
115
- const Good: any = (PlatformDeep as any) && (PlatformDeep as any).OS ? PlatformDeep : (PlatformDeep as any) && (PlatformDeep as any).default;
116
- __pfix.deep = String(Good && Good.OS);
117
- __pfix.top0 = String((Platform as any).OS);
118
- const d = (Platform as any) && Object.getOwnPropertyDescriptor(Platform, 'OS');
119
- __pfix.cfg = String(d && d.configurable);
120
- __pfix.get = String(!!(d && d.get));
121
- if ((Platform as any).OS !== 'ios') {
122
- try { Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true, writable: true, enumerable: true }); __pfix.r = 'def'; }
123
- catch (e) { try { (Platform as any).OS = 'ios'; __pfix.r = 'set'; } catch (e2) { __pfix.r = 'fail'; } }
124
- } else { __pfix.r = 'already'; }
125
- __pfix.after = String((Platform as any).OS);
126
- }
127
-
128
- // Expo Router builds its navigation tree from the route files; require.context
129
- // enumerates them (the mechanism Metro uses, made explicit for rspack).
130
- const ctx = (require as any).context('./app', true, /^\\.\\/.*\\.(tsx|ts|jsx|js)$/);
131
-
132
- export default function MirrorPrototype() {
133
- // The react-native instance bound at RENDER differs from eval-time (MF swaps it);
134
- // this one has Platform.OS=null. Force it here, in the render path expo-router uses.
135
- if ((Platform as any).OS !== 'ios') {
136
- try { Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true, writable: true, enumerable: true }); }
137
- catch (e) { try { (Platform as any).OS = 'ios'; } catch (e2) {} }
138
- }
139
- let keys = [];
140
- try { keys = ctx.keys(); } catch (e) { keys = ['ERR:' + String(e)]; }
141
- return (
142
- <View style={{ flex: 1 }}>
143
- <View style={{ position: 'absolute', top: 90, left: 8, right: 8, zIndex: 99999, backgroundColor: '#ffecec', padding: 6 }}>
144
- <Text style={{ fontSize: 12, color: '#a00', fontWeight: '700' }}>
145
- PROTO · top={String(Platform.OS)} · deepNow={String((PlatformDeep as any) && (PlatformDeep as any).OS)} · routes={keys.length}
146
- </Text>
147
- <Text style={{ fontSize: 8, color: '#a00' }}>{JSON.stringify(__pfix)}</Text>
148
- <Text style={{ fontSize: 9, color: '#a00' }}>{keys.slice(0, 8).join(' ')}</Text>
149
- </View>
150
- <ExpoRoot context={ctx} />
151
- </View>
152
- );
153
- }
154
- `;
155
-
156
- const buildEntry = (scope) => `import { AppRegistry } from 'react-native';
157
- import App from './mirror-app';
158
- AppRegistry.registerComponent(${JSON.stringify(scope)}, () => App);
159
- `;
160
-
161
- const RN_PLATFORM_LOADER_PATH = path.join(__dirname, "rn-platform-loader.js");
162
- const rspackConfig = (scope) => `import path from 'node:path';
163
- import { fileURLToPath } from 'node:url';
164
- import { createRequire } from 'node:module';
165
- import * as Repack from '@callstack/repack';
166
- import { ExpoModulesPlugin } from '@callstack/repack-plugin-expo-modules';
167
- import { ReanimatedPlugin } from '@callstack/repack-plugin-reanimated';
168
- import { DefinePlugin, NormalModuleReplacementPlugin } from '@rspack/core';
169
-
170
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
171
- const require = createRequire(import.meta.url);
172
- const PUBLIC_PATH = process.env.PUBLIC_PATH || 'http://localhost:9000/';
173
- // RN 0.85 "exports" route .../Platform to a compat shim that self-resolves
174
- // circularly → Platform.OS is null. Alias it straight to Platform.ios.js.
175
- const RN_DIR = path.dirname(require.resolve('react-native/package.json'));
176
- const PLATFORM_IOS = path.join(RN_DIR, 'Libraries/Utilities/Platform.ios.js');
177
- // Native libs (+ their deep-import path) must be single instances consumed from
178
- // the host, or their native views register twice (RCTFatal). singleton, non-eager.
179
- // The prototype consumes ONE instance of every shared-runtime lib from the host.
180
- const RUNTIME_DEPS = ${JSON.stringify(runtimeSharedDeps())};
181
- const nativeShared = (eager) => Object.fromEntries(RUNTIME_DEPS.flatMap((n) => [[n, { singleton: true, eager }], [n + '/', { singleton: true, eager }]]));
182
-
183
- // Module Federation REMOTE: one prototype, one container exposing ./App.
184
- // react/react-native are shared singletons provided by the Mirror host.
185
- export default Repack.defineRspackConfig({
186
- context: __dirname,
187
- entry: './index.js',
188
- output: { path: path.join(__dirname, 'dist'), publicPath: PUBLIC_PATH },
189
- resolve: {
190
- ...Repack.getResolveOptions('ios'),
191
- alias: {
192
- 'react-native/Libraries/Utilities/Platform$': PLATFORM_IOS,
193
- [path.join(RN_DIR, 'Libraries/Utilities/Platform.js')]: PLATFORM_IOS,
194
- 'react-native-platform-fixed': path.join(__dirname, '__platform-fixed.js'),
195
- },
196
- },
197
- module: {
198
- rules: [
199
- // PRE-loader: rewrite \`import { Platform } from 'react-native'\` → deep import
200
- // (Platform.ios.js) before swc, so the MF-null top-level Platform is bypassed.
201
- { test: /\\.[cm]?[jt]sx?$/, enforce: 'pre', use: [{ loader: ${JSON.stringify(RN_PLATFORM_LOADER_PATH)} }] },
202
- { test: /\\.[cm]?[jt]sx?$/, type: 'javascript/auto', use: { loader: '@callstack/repack/babel-swc-loader', parallel: true, options: {} } },
203
- ...Repack.getAssetTransformRules(),
204
- ],
205
- },
206
- plugins: [
207
- new Repack.RepackPlugin(),
208
- // RN 0.85's Platform compat-shim self-resolves circularly under rspack → OS is
209
- // null → expo-router/React Navigation render blank. An \`alias\` can't reach the
210
- // INTERNAL relative require react-native/index.js does (\`./Libraries/Utilities/
211
- // Platform\`), so redirect those requests straight to Platform.ios.js (OS:'ios'
212
- // is a static literal there, so it survives MF double-evaluation). Mirrors the
213
- // host's fix exactly — the container needs it too since it builds its own copy.
214
- new NormalModuleReplacementPlugin(/^\\.\\/Libraries\\/Utilities\\/Platform$/, PLATFORM_IOS),
215
- new NormalModuleReplacementPlugin(/^\\.\\/Platform$/, (r) => {
216
- const ctx = (r.context || '').split(path.sep).join('/');
217
- if (ctx.endsWith('/react-native/Libraries/Utilities')) r.request = PLATFORM_IOS;
218
- }),
219
- // Inject the env the expo-router babel plugin normally sets (our swc build
220
- // skips it). EXPO_ROUTER_APP_ROOT makes expo-router's internal _ctx resolve
221
- // to the same routes; without it the store sees no routes and renders blank.
222
- new DefinePlugin({
223
- 'process.env.EXPO_ROUTER_APP_ROOT': JSON.stringify(path.join(__dirname, 'app')),
224
- 'process.env.EXPO_ROUTER_IMPORT_MODE': JSON.stringify('sync'),
225
- 'process.env.EXPO_OS': JSON.stringify('ios'),
226
- }),
227
- new ExpoModulesPlugin(),
228
- new ReanimatedPlugin(),
229
- // reactNativeDeepImports OFF: when ON, MF routes react-native/Libraries/*
230
- // (incl. Platform) through the shared scope, which lands on the circular shim
231
- // (OS=null). OFF lets the NormalModuleReplacement above resolve Platform to
232
- // Platform.ios.js locally. react-native TOP-LEVEL stays a shared singleton.
233
- new Repack.plugins.ModuleFederationPluginV2({
234
- name: ${JSON.stringify(scope)},
235
- reactNativeDeepImports: false,
236
- filename: ${JSON.stringify(scope + ".container.js.bundle")},
237
- exposes: { './App': './mirror-app.tsx' },
238
- shared: {
239
- react: Repack.Federated.SHARED_REACT,
240
- 'react-native': Repack.Federated.SHARED_REACT_NATIVE,
241
- // Explicit SINGLETON share of the deep Platform module → collapses the
242
- // multiple instances (the proven paradox: two identical imports give
243
- // different OS) to ONE, pinned via resolve.alias to Platform.ios.js (OS:'ios').
244
- 'react-native/Libraries/Utilities/Platform': { singleton: true, eager: false, requiredVersion: false },
245
- ...nativeShared(false),
246
- },
247
- }),
248
- ],
249
- });
250
- `;
251
-
252
- // The local module the Platform pre-loader rewrites imports to. It imports the deep
253
- // Platform (which MF may hand a null-OS instance) and FORCES OS:'ios' on a copy.
254
- // Being a plain local module (not react-native), it dedupes to ONE instance — so
255
- // every consumer (app + expo-router + @react-navigation) gets a single, stable
256
- // Platform with OS:'ios'. select() is preserved/recreated (React Navigation needs it).
257
- const PLATFORM_FIXED = `import P from 'react-native/Libraries/Utilities/Platform';
258
- var Platform = P;
259
- if (!P || P.OS !== 'ios') {
260
- Platform = {};
261
- if (P) { for (var k in P) { try { Platform[k] = P[k]; } catch (e) {} } }
262
- Platform.OS = 'ios';
263
- if (typeof Platform.select !== 'function') {
264
- Platform.select = function (o) { return o && (o.ios !== undefined ? o.ios : (o.native !== undefined ? o.native : o.default)); };
265
- }
266
- }
267
- export default Platform;
268
- `;
269
- const BABEL_CONFIG = `module.exports = { presets: ['babel-preset-expo'] };\n`;
270
- const RN_CONFIG = `module.exports = { commands: require('@callstack/repack/commands/rspack') };\n`;
271
-
272
- function stageFederation({ root, stage, scope, ad }) {
273
- // 1. author's routes → stage/app (normalize src/app | app → app)
274
- fs.cpSync(ad, path.join(stage, "app"), { recursive: true });
275
- const assets = path.join(root, "assets");
276
- if (fs.existsSync(assets)) fs.cpSync(assets, path.join(stage, "assets"), { recursive: true });
277
-
278
- // 2. generated federation machinery
279
- fs.writeFileSync(path.join(stage, "mirror-app.tsx"), MIRROR_ENTRY);
280
- fs.writeFileSync(path.join(stage, "index.js"), buildEntry(scope));
281
- fs.writeFileSync(path.join(stage, "rspack.config.mjs"), rspackConfig(scope));
282
- fs.writeFileSync(path.join(stage, "babel.config.js"), BABEL_CONFIG);
283
- fs.writeFileSync(path.join(stage, "__platform-fixed.js"), PLATFORM_FIXED);
284
- fs.writeFileSync(path.join(stage, "react-native.config.js"), RN_CONFIG);
285
-
286
- // 3. package.json from the shared runtime (versions match the symlinked modules)
287
- const runtimePkg = JSON.parse(fs.readFileSync(path.join(runtimeDir(), "package.json"), "utf8"));
288
- runtimePkg.name = scope;
289
- runtimePkg.private = true;
290
- fs.writeFileSync(path.join(stage, "package.json"), JSON.stringify(runtimePkg, null, 2) + "\n");
291
- for (const f of ["tsconfig.json", "app.json", "expo-env.d.ts"]) {
292
- const src = path.join(root, f);
293
- if (fs.existsSync(src)) fs.cpSync(src, path.join(stage, f));
294
- }
295
-
296
- // 4. node_modules → shared runtime (app deps + Re.Pack toolchain live there)
297
- const target = fs.realpathSync(path.join(runtimeDir(), "node_modules"));
298
- const link = path.join(stage, "node_modules");
299
- try { fs.unlinkSync(link); } catch {}
300
- fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
301
- }
302
-
303
- function buildContainer({ stage, slug }) {
304
- const publicPath = `${PUBLIC_BASE}/p/${slug}/`;
305
- run("npx", ["react-native", "bundle", "--platform", "ios", "--entry-file", "index.js",
306
- "--bundle-output", "dist/index.bundle", "--assets-dest", "dist", "--dev", "false"],
307
- { cwd: stage, env: { ...process.env, PUBLIC_PATH: publicPath } });
308
- return path.join(stage, "dist");
309
- }
310
-
311
- // ---- upload + registry ------------------------------------------------------
312
-
313
- const contentType = (f) =>
314
- f.endsWith(".json") ? "application/json"
315
- : f.endsWith(".bundle") || f.endsWith(".js") ? "application/javascript"
316
- : f.endsWith(".png") ? "image/png"
317
- : f.endsWith(".jpg") || f.endsWith(".jpeg") ? "image/jpeg"
318
- : f.endsWith(".ttf") ? "font/ttf"
319
- : f.endsWith(".otf") ? "font/otf"
320
- : "application/octet-stream";
321
-
322
- // Upload the whole dist tree under p/<slug>/, skipping dev-only artifacts.
323
- async function uploadDist(distDir, slug) {
324
- const skip = (rel) => rel.endsWith(".map") || rel === "index.bundle" || rel.endsWith("mf-stats.json");
325
- const files = [];
326
- (function walk(dir, rel) {
327
- for (const name of fs.readdirSync(dir)) {
328
- const abs = path.join(dir, name);
329
- const r = rel ? `${rel}/${name}` : name;
330
- if (fs.statSync(abs).isDirectory()) walk(abs, r);
331
- else if (!skip(r)) files.push(r);
332
- }
333
- })(distDir, "");
334
- for (const rel of files) {
335
- await _s3Put(`p/${slug}/${rel}`, fs.readFileSync(path.join(distDir, rel)), contentType(rel));
336
- log(c.dim(` ↑ p/${slug}/${rel}`));
337
- }
338
- return files.length;
339
- }
340
-
341
- async function readIndex() {
342
- try {
343
- const res = await fetch(`${PUBLIC_BASE}/index.json?t=${Date.now()}`);
344
- if (!res.ok) return { schema: "mirror-mf-1", prototypes: [] };
345
- const data = await res.json();
346
- if (Array.isArray(data)) return { schema: "mirror-mf-1", prototypes: data };
347
- return { schema: data.schema || "mirror-mf-1", prototypes: Array.isArray(data.prototypes) ? data.prototypes : [] };
348
- } catch {
349
- return { schema: "mirror-mf-1", prototypes: [] };
350
- }
351
- }
352
-
353
- async function upsertIndex(entry) {
354
- const index = await readIndex();
355
- index.prototypes = index.prototypes.filter((e) => e.id !== entry.id);
356
- index.prototypes.unshift(entry); // newest first
357
- await _s3Put("index.json", JSON.stringify(index, null, 2), "application/json");
358
- }
359
-
360
- // ---- command ----------------------------------------------------------------
361
-
362
- async function cmdPush(args) {
363
- const root = process.cwd();
364
- const dry = has(args, "--dry-run");
365
- const buildOnly = has(args, "--build-only"); // build + keep stage, no upload (for local testing)
366
-
367
- const pkgPath = path.join(root, "package.json");
368
- if (!fs.existsSync(pkgPath)) throw new Error("no package.json here — run `calo-design push` from inside a prototype folder.");
369
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
370
- if (!(pkg.dependencies && pkg.dependencies.expo)) throw new Error("this folder isn't an Expo project.");
371
- const ad = appDir(root);
372
- if (!ad) throw new Error("no routes found (expected src/app or app).");
373
-
374
- const slug = slugify(flag(args, "--slug", path.basename(root)));
375
- const scope = scopeOf(slug);
376
- const title = flag(args, "--title", titleize(pkg.name || slug));
377
- const owner = flag(args, "--owner", gitName() || os.userInfo().username || "");
378
- const note = flag(args, "--note", "");
379
- const screenshot = findScreenshot(root, flag(args, "--screenshot", ""));
380
-
381
- log(c.b(`\n[push] ${title}`) + c.dim(` (slug: ${slug}, scope: ${scope})`));
382
-
383
- const stage = fs.mkdtempSync(path.join(os.tmpdir(), `calo-fed-${slug}-`));
384
- let keepStage = buildOnly;
385
- try {
386
- stageFederation({ root, stage, scope, ad });
387
- log(c.dim(` staged → ${stage}`));
388
-
389
- if (dry) {
390
- keepStage = true;
391
- ok(`dry-run — staged a federation build at:\n ${stage}`);
392
- log(c.dim(` would build container ${scope}.container.js.bundle and upload to ${PUBLIC_BASE}/p/${slug}/`));
393
- return;
394
- }
395
-
396
- log(c.dim(" building Module Federation container…"));
397
- const distDir = buildContainer({ stage, slug });
398
- const container = `${scope}.container.js.bundle`;
399
- if (!fs.existsSync(path.join(distDir, container))) throw new Error(`build did not emit ${container}`);
400
- ok(`built ${container}`);
401
-
402
- if (buildOnly) {
403
- ok(`build-only — container + chunks in:\n ${distDir}`);
404
- return;
405
- }
406
-
407
- const n = await uploadDist(distDir, slug);
408
- ok(`uploaded ${n} files → ${PUBLIC_BASE}/p/${slug}/`);
409
-
410
- const screenshotUrl = screenshot
411
- ? (await _s3Put(`screenshots/${slug}.png`, fs.readFileSync(screenshot), "image/png"), `${PUBLIC_BASE}/screenshots/${slug}.png`)
412
- : undefined;
413
-
414
- await upsertIndex({
415
- id: slug, name: title, owner, note: note || undefined,
416
- scope, entry: `${PUBLIC_BASE}/p/${slug}/${container}`,
417
- screenshotUrl, updatedAt: new Date().toISOString(),
418
- });
419
- ok("registry updated");
420
- log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and pull to refresh.");
421
- } finally {
422
- if (!keepStage) fs.rmSync(stage, { recursive: true, force: true });
423
- else log(c.dim(` (kept stage: ${stage})`));
424
- }
425
- }
426
-
427
- module.exports = { cmdPush, _scopeOf: scopeOf, _stageFederation: stageFederation };
@@ -1,43 +0,0 @@
1
- // rspack PRE-loader: rewrite `import { Platform } from 'react-native'` to
2
- // `import Platform from 'react-native-platform-fixed'` (a local module aliased in
3
- // the rspack config).
4
- //
5
- // Why: under Module Federation in this stack the CONSUMED react-native's Platform
6
- // resolves to different instances per import binding — even two byte-identical
7
- // `import X from 'react-native/Libraries/Utilities/Platform'` in one file give one
8
- // null and one 'ios'. No resolve/runtime/source fix is reliable against that. The
9
- // local `react-native-platform-fixed` module dedupes normally (it isn't react-native,
10
- // so it escapes the MF instance roulette) and forces OS:'ios' regardless of what the
11
- // underlying Platform instance it sees has. Rewriting at the source level (before
12
- // swc) routes app code AND node_modules (expo-router, @react-navigation) to it.
13
- module.exports = function rnPlatformLoader(source) {
14
- if (typeof source !== 'string' || source.indexOf('react-native') === -1 || source.indexOf('Platform') === -1) {
15
- return source;
16
- }
17
- var re0 = /import\s+(?:(\w+)\s*,\s*)?\{([^}]*)\}\s*from\s*(['"])react-native\3/;
18
- if (re0.test(source) && /(^|[,{\s])Platform(\s*,|\s*}|\s+as\s)/.test(source.match(re0)[0])) {
19
- try { require('fs').appendFileSync('/tmp/rnplat.log', 'XFORM ' + ((this && this.resourcePath) || '?') + '\n'); } catch (e) {}
20
- }
21
- var re = /import\s+(?:(\w+)\s*,\s*)?\{([^}]*)\}\s*from\s*(['"])react-native\3/g;
22
- return source.replace(re, function (full, dflt, names) {
23
- if (names.indexOf('Platform') === -1) return full;
24
- var parts = names.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
25
- var kept = [];
26
- var local = 'Platform';
27
- var found = false;
28
- for (var k = 0; k < parts.length; k++) {
29
- var m = parts[k].match(/^Platform(?:\s+as\s+(\w+))?$/);
30
- if (m) { local = m[1] || 'Platform'; found = true; continue; }
31
- kept.push(parts[k]);
32
- }
33
- if (!found) return full; // 'Platform' only appeared as a substring (e.g. PlatformColor)
34
- var out = '';
35
- if (dflt) {
36
- out += 'import ' + dflt + (kept.length ? ', { ' + kept.join(', ') + ' }' : '') + " from 'react-native';\n";
37
- } else if (kept.length) {
38
- out += 'import { ' + kept.join(', ') + " } from 'react-native';\n";
39
- }
40
- out += 'import ' + local + " from 'react-native/Libraries/Utilities/Platform';";
41
- return out;
42
- });
43
- };