@calo-design/cli 0.1.0
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 +53 -0
- package/bin/cli.js +470 -0
- package/bin/login.js +150 -0
- package/bin/mirror-federate.js +427 -0
- package/bin/mirror-push.js +472 -0
- package/bin/rn-platform-loader.js +43 -0
- package/package.json +10 -0
|
@@ -0,0 +1,427 @@
|
|
|
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 };
|