@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,472 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `calo-design push` — publish a local prototype to the Calo Mirror.
|
|
5
|
+
*
|
|
6
|
+
* No git. Exactly two side effects:
|
|
7
|
+
* 1) eas update → the prototype's OWN channel inside the ONE shared EAS project
|
|
8
|
+
* (the native JS bundle the Mirror shell reloads into).
|
|
9
|
+
* 2) Tigris → upsert index.json + upload screenshots/<slug>.png (the feed).
|
|
10
|
+
*
|
|
11
|
+
* The prototype is authored as a plain Expo app. This command stages a managed
|
|
12
|
+
* copy (shared EAS project id, fixed runtimeVersion, an injected "back to Mirror"
|
|
13
|
+
* control) so the author never touches any of the loader machinery.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { spawnSync } = require("node:child_process");
|
|
17
|
+
const crypto = require("node:crypto");
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const os = require("node:os");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
|
|
22
|
+
// ---- shared contract with the Mirror shell (calo-design-mirror) -------------
|
|
23
|
+
const SHARED_PROJECT_ID = "290a759f-427c-432e-9ab5-dab98310e66b";
|
|
24
|
+
const UPDATES_URL = `https://u.expo.dev/${SHARED_PROJECT_ID}`;
|
|
25
|
+
const RUNTIME_VERSION = "0.1.0"; // must equal the shell binary's runtimeVersion (appVersion policy, version 0.1.0)
|
|
26
|
+
const LAUNCHER_CHANNEL = "mirror";
|
|
27
|
+
|
|
28
|
+
// ---- registry (Fly Tigris, S3-compatible; override via env) -----------------
|
|
29
|
+
const BUCKET = process.env.CALO_MIRROR_BUCKET || "calo-design-mirror";
|
|
30
|
+
const ENDPOINT = process.env.CALO_MIRROR_ENDPOINT || "https://fly.storage.tigris.dev";
|
|
31
|
+
const PUBLIC_BASE = process.env.CALO_MIRROR_PUBLIC_BASE || `https://${BUCKET}.fly.storage.tigris.dev`;
|
|
32
|
+
// Tigris is S3-compatible, so `fly storage` hands back AWS_*-named keys (that's
|
|
33
|
+
// the S3 naming convention, not Amazon). We prefer our own CALO_MIRROR_* names.
|
|
34
|
+
const TIGRIS_KEY = process.env.CALO_MIRROR_KEY || process.env.AWS_ACCESS_KEY_ID;
|
|
35
|
+
const TIGRIS_SECRET = process.env.CALO_MIRROR_SECRET || process.env.AWS_SECRET_ACCESS_KEY;
|
|
36
|
+
|
|
37
|
+
const c = {
|
|
38
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
39
|
+
g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
40
|
+
};
|
|
41
|
+
const log = (s = "") => console.log(s);
|
|
42
|
+
const ok = (s) => log(`${c.g("✓")} ${s}`);
|
|
43
|
+
const warn = (s) => log(`${c.y("!")} ${s}`);
|
|
44
|
+
|
|
45
|
+
// ---- tiny arg + process helpers ---------------------------------------------
|
|
46
|
+
const flag = (args, name, def) => {
|
|
47
|
+
const i = args.indexOf(name);
|
|
48
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : def;
|
|
49
|
+
};
|
|
50
|
+
const has = (args, name) => args.includes(name);
|
|
51
|
+
|
|
52
|
+
function run(bin, argv, opts = {}) {
|
|
53
|
+
const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
|
|
54
|
+
if (r.error) throw r.error;
|
|
55
|
+
if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
|
|
56
|
+
return r;
|
|
57
|
+
}
|
|
58
|
+
function capture(bin, argv, opts = {}) {
|
|
59
|
+
const r = spawnSync(bin, argv, { encoding: "utf8", ...opts });
|
|
60
|
+
return { status: r.status == null ? 1 : r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Resolve the eas CLI once: prefer one on PATH (global install), else fetch via
|
|
64
|
+
// npx. `push` runs from a prototype folder that won't have eas-cli locally.
|
|
65
|
+
let _easPrefix;
|
|
66
|
+
function easPrefix() {
|
|
67
|
+
if (!_easPrefix) {
|
|
68
|
+
const onPath = spawnSync(process.platform === "win32" ? "where" : "which", ["eas"], { stdio: "ignore" }).status === 0;
|
|
69
|
+
_easPrefix = onPath ? ["eas"] : ["npx", "-y", "-p", "eas-cli", "eas"];
|
|
70
|
+
}
|
|
71
|
+
return _easPrefix;
|
|
72
|
+
}
|
|
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); };
|
|
75
|
+
|
|
76
|
+
function runtimeDir() {
|
|
77
|
+
const home = process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
|
|
78
|
+
return path.join(home, "runtime");
|
|
79
|
+
}
|
|
80
|
+
const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "prototype";
|
|
81
|
+
const titleize = (s) => String(s).replace(/[-_]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase());
|
|
82
|
+
|
|
83
|
+
function appDir(root) {
|
|
84
|
+
for (const d of [path.join(root, "src", "app"), path.join(root, "app")]) if (fs.existsSync(d)) return d;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function gitName() {
|
|
88
|
+
const r = capture("git", ["config", "user.name"]);
|
|
89
|
+
return r.status === 0 ? r.out.trim() : "";
|
|
90
|
+
}
|
|
91
|
+
function findScreenshot(root, explicit) {
|
|
92
|
+
if (explicit) return explicit;
|
|
93
|
+
for (const p of ["screenshot.png", path.join("assets", "screenshot.png"), path.join("assets", "preview.png")]) {
|
|
94
|
+
if (fs.existsSync(path.join(root, p))) return path.join(root, p);
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- staging ----------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
// The prototype's deps must be a subset of the shared runtime — the shell binary
|
|
102
|
+
// only contains the runtime's native modules, so anything extra would be missing.
|
|
103
|
+
function validateDeps(pkg) {
|
|
104
|
+
const runtimePkgPath = path.join(runtimeDir(), "package.json");
|
|
105
|
+
if (!fs.existsSync(runtimePkgPath)) {
|
|
106
|
+
warn("no shared runtime found — skipping dependency-containment check.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const runtimeDeps = new Set(Object.keys(JSON.parse(fs.readFileSync(runtimePkgPath, "utf8")).dependencies || {}));
|
|
110
|
+
const extras = Object.keys(pkg.dependencies || {}).filter((d) => !d.startsWith("@calo/") && !runtimeDeps.has(d));
|
|
111
|
+
if (extras.length) {
|
|
112
|
+
warn(`these deps aren't in the shared runtime, so the Mirror binary can't provide them:\n ${extras.join(", ")}`);
|
|
113
|
+
warn("the prototype may crash on load in Mirror. Keep prototypes to the shared-runtime deps, or ask for a runtime bump.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeJSON(p, obj) {
|
|
118
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stageProject({ root, stage, slug, title, ad }) {
|
|
122
|
+
// Copy the author's routes + assets verbatim.
|
|
123
|
+
const stageSrcParent = path.join(stage, path.relative(root, path.dirname(ad)));
|
|
124
|
+
fs.mkdirSync(stageSrcParent, { recursive: true });
|
|
125
|
+
fs.cpSync(ad, path.join(stageSrcParent, path.basename(ad)), { recursive: true });
|
|
126
|
+
const assets = path.join(root, "assets");
|
|
127
|
+
if (fs.existsSync(assets)) fs.cpSync(assets, path.join(stage, "assets"), { recursive: true });
|
|
128
|
+
|
|
129
|
+
const usesSrc = path.basename(path.dirname(ad)) === "src";
|
|
130
|
+
const stagedAppDir = path.join(stage, usesSrc ? "src" : "", "app");
|
|
131
|
+
// mirror-chrome.tsx is written into mirrorChromeDir, which is always ONE level
|
|
132
|
+
// above the app dir (stage/ for app/, stage/src/ for src/app/). The _layout that
|
|
133
|
+
// imports it lives inside the app dir, so the import is always "../mirror-chrome".
|
|
134
|
+
// (Previously "./mirror-chrome" for the non-src case, which failed to resolve.)
|
|
135
|
+
const libRel = "../mirror-chrome";
|
|
136
|
+
const mirrorChromeDir = usesSrc ? path.join(stage, "src") : stage;
|
|
137
|
+
|
|
138
|
+
// Managed app.json: shared EAS project, fixed runtimeVersion, override allowed.
|
|
139
|
+
writeJSON(path.join(stage, "app.json"), {
|
|
140
|
+
expo: {
|
|
141
|
+
name: title,
|
|
142
|
+
slug: "designchef",
|
|
143
|
+
scheme: `calo-${slug}`,
|
|
144
|
+
version: "1.0.0",
|
|
145
|
+
runtimeVersion: RUNTIME_VERSION,
|
|
146
|
+
orientation: "portrait",
|
|
147
|
+
userInterfaceStyle: "light",
|
|
148
|
+
newArchEnabled: true,
|
|
149
|
+
plugins: ["expo-router", "expo-font", "expo-updates"],
|
|
150
|
+
updates: { url: UPDATES_URL, disableAntiBrickingMeasures: true },
|
|
151
|
+
extra: { router: {}, eas: { projectId: SHARED_PROJECT_ID } },
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
// package.json copied from the runtime so versions match the symlinked node_modules.
|
|
155
|
+
const runtimePkg = JSON.parse(fs.readFileSync(path.join(runtimeDir(), "package.json"), "utf8"));
|
|
156
|
+
runtimePkg.name = `calo-${slug}`;
|
|
157
|
+
runtimePkg.private = true;
|
|
158
|
+
writeJSON(path.join(stage, "package.json"), runtimePkg);
|
|
159
|
+
for (const f of ["tsconfig.json", "babel.config.js"]) {
|
|
160
|
+
const src = path.join(runtimeDir(), f);
|
|
161
|
+
if (fs.existsSync(src)) fs.cpSync(src, path.join(stage, f));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
writeMetroConfig(stage);
|
|
165
|
+
linkNodeModules(stage);
|
|
166
|
+
injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel });
|
|
167
|
+
|
|
168
|
+
// EAS Update enumerates project files via git. Give the throwaway staging dir
|
|
169
|
+
// its own local repo (node_modules ignored). This never leaves the temp dir —
|
|
170
|
+
// it's not a "git op" in the user-facing sense, just what `eas update` needs.
|
|
171
|
+
fs.writeFileSync(path.join(stage, ".gitignore"), "node_modules/\n.expo/\ndist/\n*.log\n");
|
|
172
|
+
run("git", ["-C", stage, "init", "-q"]);
|
|
173
|
+
run("git", ["-C", stage, "add", "-A"]);
|
|
174
|
+
run("git", ["-C", stage, "-c", "user.email=mirror@calo.app", "-c", "user.name=Calo Mirror", "commit", "-q", "-m", "mirror push"]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function writeMetroConfig(stage) {
|
|
178
|
+
const runtimeReal = fs.realpathSync(runtimeDir());
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(stage, "metro.config.js"),
|
|
181
|
+
`process.env.EXPO_NO_METRO_WORKSPACE_ROOT = "1";
|
|
182
|
+
const path = require("node:path");
|
|
183
|
+
const { getDefaultConfig } = require("expo/metro-config");
|
|
184
|
+
const projectRoot = __dirname;
|
|
185
|
+
const runtimeRoot = ${JSON.stringify(runtimeReal)};
|
|
186
|
+
const config = getDefaultConfig(projectRoot);
|
|
187
|
+
config.watchFolders = [...(config.watchFolders || []), runtimeRoot];
|
|
188
|
+
config.resolver.nodeModulesPaths = [
|
|
189
|
+
path.resolve(projectRoot, "node_modules"),
|
|
190
|
+
path.resolve(runtimeRoot, "node_modules"),
|
|
191
|
+
];
|
|
192
|
+
module.exports = config;
|
|
193
|
+
`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function linkNodeModules(stage) {
|
|
198
|
+
const target = fs.realpathSync(path.join(runtimeDir(), "node_modules"));
|
|
199
|
+
const link = path.join(stage, "node_modules");
|
|
200
|
+
try { fs.unlinkSync(link); } catch {}
|
|
201
|
+
fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Overwrite the root _layout with a managed one that wraps the app in a floating
|
|
205
|
+
// "← Mirror" control. NOTE (v1): a custom root _layout (custom providers/Tabs) is
|
|
206
|
+
// not preserved — we warn when the layout looks non-standard.
|
|
207
|
+
function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel }) {
|
|
208
|
+
const layout = path.join(stagedAppDir, "_layout.tsx");
|
|
209
|
+
if (fs.existsSync(layout)) {
|
|
210
|
+
const cur = fs.readFileSync(layout, "utf8");
|
|
211
|
+
const standard = /useFonts\(caloFonts\)/.test(cur) && /<Stack/.test(cur);
|
|
212
|
+
if (!standard) warn("custom root _layout detected — Mirror wraps it with a Stack; custom providers won't carry over.");
|
|
213
|
+
}
|
|
214
|
+
fs.writeFileSync(
|
|
215
|
+
layout,
|
|
216
|
+
`import { Stack } from "expo-router";
|
|
217
|
+
import { useFonts } from "expo-font";
|
|
218
|
+
import { caloFonts } from "@calo/design-system";
|
|
219
|
+
import { MirrorChrome } from "${libRel}";
|
|
220
|
+
|
|
221
|
+
export default function RootLayout() {
|
|
222
|
+
const [loaded] = useFonts(caloFonts);
|
|
223
|
+
if (!loaded) return null;
|
|
224
|
+
return (
|
|
225
|
+
<MirrorChrome>
|
|
226
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
227
|
+
</MirrorChrome>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
`
|
|
231
|
+
);
|
|
232
|
+
fs.writeFileSync(
|
|
233
|
+
path.join(mirrorChromeDir, "mirror-chrome.tsx"),
|
|
234
|
+
`import type { ReactNode } from "react";
|
|
235
|
+
import { useState } from "react";
|
|
236
|
+
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
|
237
|
+
import * as Updates from "expo-updates";
|
|
238
|
+
|
|
239
|
+
// One return at a time. A second tap before the reload lands fires a second
|
|
240
|
+
// reloadAsync(), racing two runtime teardowns -> SIGBUS in ExpoModulesJSI. The
|
|
241
|
+
// latch (plus the disabled button) drops extra taps; a failure clears it to retry.
|
|
242
|
+
let returning = false;
|
|
243
|
+
|
|
244
|
+
// Injected by \`calo-design push\`. Flips the binary back to the Mirror launcher.
|
|
245
|
+
async function backToMirror() {
|
|
246
|
+
if (returning || !Updates.isEnabled) return;
|
|
247
|
+
returning = true;
|
|
248
|
+
try {
|
|
249
|
+
await Updates.setUpdateURLAndRequestHeadersOverride({
|
|
250
|
+
updateUrl: ${JSON.stringify(UPDATES_URL)},
|
|
251
|
+
requestHeaders: {
|
|
252
|
+
"expo-channel-name": ${JSON.stringify(LAUNCHER_CHANNEL)},
|
|
253
|
+
"expo-runtime-version": Updates.runtimeVersion ?? ${JSON.stringify(RUNTIME_VERSION)},
|
|
254
|
+
"expo-platform": Platform.OS,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
// EAS requires channel + runtime-version + platform (400 otherwise); the
|
|
258
|
+
// override replaces headers, so send all three, then download.
|
|
259
|
+
await Updates.fetchUpdateAsync();
|
|
260
|
+
} catch {
|
|
261
|
+
returning = false;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Defer the reload to a fresh macrotask. Reloading inside the awaited chain
|
|
265
|
+
// races the fetch's JSI promise + the expo-updates reaper teardown -> SIGBUS
|
|
266
|
+
// in ExpoModulesJSI. Let the chain unwind + reaper settle, then reload.
|
|
267
|
+
// reloadAsync never resolves (the app reloads), so it is not awaited.
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
void Updates.reloadAsync().catch(() => {
|
|
270
|
+
returning = false;
|
|
271
|
+
});
|
|
272
|
+
}, 300);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function MirrorChrome({ children }: { children: ReactNode }) {
|
|
276
|
+
const [going, setGoing] = useState(false);
|
|
277
|
+
return (
|
|
278
|
+
<View style={{ flex: 1 }}>
|
|
279
|
+
{children}
|
|
280
|
+
<Pressable
|
|
281
|
+
accessibilityLabel="Back to Mirror"
|
|
282
|
+
accessibilityRole="button"
|
|
283
|
+
disabled={going}
|
|
284
|
+
onPress={() => {
|
|
285
|
+
setGoing(true);
|
|
286
|
+
void backToMirror();
|
|
287
|
+
}}
|
|
288
|
+
hitSlop={8}
|
|
289
|
+
style={({ pressed }) => ({
|
|
290
|
+
position: "absolute",
|
|
291
|
+
left: 16,
|
|
292
|
+
bottom: 36,
|
|
293
|
+
flexDirection: "row",
|
|
294
|
+
alignItems: "center",
|
|
295
|
+
gap: 8,
|
|
296
|
+
backgroundColor: "#208AEF",
|
|
297
|
+
paddingHorizontal: 16,
|
|
298
|
+
paddingVertical: 10,
|
|
299
|
+
borderRadius: 999,
|
|
300
|
+
opacity: going ? 0.85 : pressed ? 0.7 : 1,
|
|
301
|
+
transform: [{ scale: pressed ? 0.96 : 1 }],
|
|
302
|
+
})}
|
|
303
|
+
>
|
|
304
|
+
{going ? <ActivityIndicator size="small" color="#fff" /> : null}
|
|
305
|
+
<Text style={{ color: "#fff", fontWeight: "600" }}>{going ? "Returning…" : "← Mirror"}</Text>
|
|
306
|
+
</Pressable>
|
|
307
|
+
</View>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- publish ----------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
function ensureEas() {
|
|
317
|
+
const who = easCapture(["whoami"]);
|
|
318
|
+
if (who.status !== 0) {
|
|
319
|
+
throw new Error("EAS not ready — run `eas login` (the account that owns the shared project). eas-cli is fetched via npx if it isn't installed.");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// `channel:create` makes a channel + same-named branch and links them. Idempotent
|
|
324
|
+
// for our purposes: if it already exists we ignore the error and publish to it.
|
|
325
|
+
function ensureChannel(stage, slug) {
|
|
326
|
+
const r = easCapture(["channel:create", slug, "--non-interactive"], { cwd: stage });
|
|
327
|
+
if (r.status !== 0 && !/already exists/i.test(r.out)) {
|
|
328
|
+
// Not fatal — the branch may already exist; `eas update --branch` still works.
|
|
329
|
+
warn(`channel:create ${slug}: ${r.out.trim().split("\n").slice(-1)[0] || r.status}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- registry: Fly Tigris object storage ------------------------------------
|
|
334
|
+
// Tigris speaks the S3 API, so authenticated writes use S3 Signature V4. SigV4
|
|
335
|
+
// is a wire protocol — the `s3` / `aws4_request` / `AWS4-HMAC-SHA256` literals
|
|
336
|
+
// below are part of it, NOT Amazon AWS. No AWS account, no SDK, no aws CLI: just
|
|
337
|
+
// Node's built-in crypto computing the signature Tigris expects. Reads are plain
|
|
338
|
+
// public HTTPS against the Fly bucket URL.
|
|
339
|
+
|
|
340
|
+
const sha256hex = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
341
|
+
const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
|
|
342
|
+
|
|
343
|
+
function tigrisCreds() {
|
|
344
|
+
if (!TIGRIS_KEY || !TIGRIS_SECRET) {
|
|
345
|
+
throw new Error("Missing Tigris credentials — `source ~/.designchef/mirror.env` (or set CALO_MIRROR_KEY + CALO_MIRROR_SECRET) before push.");
|
|
346
|
+
}
|
|
347
|
+
return { key: TIGRIS_KEY, secret: TIGRIS_SECRET };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Signed PUT to the Tigris bucket (S3 API, path-style: ENDPOINT/BUCKET/key).
|
|
351
|
+
async function s3Put(objectKey, body, contentType) {
|
|
352
|
+
const { key, secret } = tigrisCreds();
|
|
353
|
+
const region = "auto";
|
|
354
|
+
const url = new URL(`${ENDPOINT}/${BUCKET}/${objectKey}`);
|
|
355
|
+
const payload = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
356
|
+
const payloadHash = sha256hex(payload);
|
|
357
|
+
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); // YYYYMMDDTHHMMSSZ
|
|
358
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
359
|
+
|
|
360
|
+
const signed = {
|
|
361
|
+
"content-type": contentType,
|
|
362
|
+
host: url.host,
|
|
363
|
+
"x-amz-content-sha256": payloadHash,
|
|
364
|
+
"x-amz-date": amzDate,
|
|
365
|
+
};
|
|
366
|
+
const names = Object.keys(signed).sort();
|
|
367
|
+
const canonicalHeaders = names.map((h) => `${h}:${signed[h]}\n`).join("");
|
|
368
|
+
const signedHeaders = names.join(";");
|
|
369
|
+
const canonicalUri = url.pathname.split("/").map(encodeURIComponent).join("/");
|
|
370
|
+
const canonicalRequest = ["PUT", canonicalUri, "", canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
|
371
|
+
const scope = `${dateStamp}/${region}/s3/aws4_request`;
|
|
372
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
|
|
373
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${secret}`, dateStamp), region), "s3"), "aws4_request");
|
|
374
|
+
const signature = crypto.createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
375
|
+
|
|
376
|
+
// Host is added by the runtime and matches the signed value, so we don't resend it.
|
|
377
|
+
const res = await fetch(url, {
|
|
378
|
+
method: "PUT",
|
|
379
|
+
headers: {
|
|
380
|
+
"content-type": contentType,
|
|
381
|
+
"x-amz-content-sha256": payloadHash,
|
|
382
|
+
"x-amz-date": amzDate,
|
|
383
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${key}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
384
|
+
},
|
|
385
|
+
body: payload,
|
|
386
|
+
});
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
const text = await res.text().catch(() => "");
|
|
389
|
+
throw new Error(`Tigris PUT ${objectKey} → HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function readRegistry() {
|
|
394
|
+
try {
|
|
395
|
+
const res = await fetch(`${PUBLIC_BASE}/index.json?t=${Date.now()}`);
|
|
396
|
+
if (!res.ok) return [];
|
|
397
|
+
const data = await res.json();
|
|
398
|
+
if (Array.isArray(data)) return data;
|
|
399
|
+
return Array.isArray(data.prototypes) ? data.prototypes : [];
|
|
400
|
+
} catch {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function uploadScreenshot(slug, file) {
|
|
406
|
+
await s3Put(`screenshots/${slug}.png`, fs.readFileSync(file), "image/png");
|
|
407
|
+
return `${PUBLIC_BASE}/screenshots/${slug}.png`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function upsertRegistry(entry) {
|
|
411
|
+
const list = await readRegistry();
|
|
412
|
+
const next = list.filter((e) => e.slug !== entry.slug);
|
|
413
|
+
next.push(entry);
|
|
414
|
+
await s3Put("index.json", JSON.stringify(next, null, 2), "application/json");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---- command ----------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
async function cmdPush(args) {
|
|
420
|
+
const root = process.cwd();
|
|
421
|
+
const dry = has(args, "--dry-run");
|
|
422
|
+
|
|
423
|
+
const pkgPath = path.join(root, "package.json");
|
|
424
|
+
if (!fs.existsSync(pkgPath)) throw new Error("no package.json here — run `calo-design push` from inside a prototype folder.");
|
|
425
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
426
|
+
if (!(pkg.dependencies && pkg.dependencies.expo)) throw new Error("this folder isn't an Expo project.");
|
|
427
|
+
const ad = appDir(root);
|
|
428
|
+
if (!ad) throw new Error("no routes found (expected src/app or app).");
|
|
429
|
+
|
|
430
|
+
const slug = slugify(flag(args, "--slug", path.basename(root)));
|
|
431
|
+
const title = flag(args, "--title", titleize(pkg.name || slug));
|
|
432
|
+
const owner = flag(args, "--owner", gitName() || os.userInfo().username || "");
|
|
433
|
+
const message = flag(args, "--message", `Update ${slug}`);
|
|
434
|
+
const description = flag(args, "--description", "");
|
|
435
|
+
const screenshot = findScreenshot(root, flag(args, "--screenshot", ""));
|
|
436
|
+
|
|
437
|
+
log(c.b(`\n[push] ${title}`) + c.dim(` (slug/channel: ${slug}, runtimeVersion: ${RUNTIME_VERSION})`));
|
|
438
|
+
validateDeps(pkg);
|
|
439
|
+
|
|
440
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), `calo-push-${slug}-`));
|
|
441
|
+
let keepStage = false;
|
|
442
|
+
try {
|
|
443
|
+
stageProject({ root, stage, slug, title, ad });
|
|
444
|
+
|
|
445
|
+
if (dry) {
|
|
446
|
+
keepStage = true;
|
|
447
|
+
ok(`dry-run — staged a managed copy at:\n ${stage}`);
|
|
448
|
+
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)")));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
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");
|
|
465
|
+
|
|
466
|
+
log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and tap Refresh — your prototype is at the top.");
|
|
467
|
+
} finally {
|
|
468
|
+
if (!keepStage) fs.rmSync(stage, { recursive: true, force: true });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry };
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@calo-design/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"bin": { "calo-design": "bin/cli.js" },
|
|
6
|
+
"files": ["bin"],
|
|
7
|
+
"publishConfig": { "access": "public" },
|
|
8
|
+
"license": "UNLICENSED",
|
|
9
|
+
"engines": { "node": ">=18" }
|
|
10
|
+
}
|