@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 +22 -0
- package/bin/cli.js +2 -2
- package/bin/login.js +16 -1
- package/bin/mirror-push.js +55 -15
- package/package.json +1 -1
- package/bin/mirror-federate.js +0 -427
- package/bin/rn-platform-loader.js +0 -43
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 (
|
|
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
|
-
|
|
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 };
|
package/bin/mirror-push.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
slug,
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
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"],
|
package/bin/mirror-federate.js
DELETED
|
@@ -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
|
-
};
|