@shipvo/cli 0.1.5 → 0.1.6
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 +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +68 -9
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +28 -11
- package/dist/autohalt.d.ts +1 -1
- package/dist/autohalt.js +28 -16
- package/dist/bsdiff.d.ts +4 -0
- package/dist/bsdiff.js +465 -0
- package/dist/ci-comment.d.ts +38 -0
- package/dist/ci-comment.js +169 -0
- package/dist/ci-compare.d.ts +10 -0
- package/dist/ci-compare.js +25 -0
- package/dist/ci-shots.d.ts +9 -0
- package/dist/ci-shots.js +81 -0
- package/dist/ci.d.ts +8 -0
- package/dist/ci.js +64 -6
- package/dist/cli.js +23 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/doctor.js +63 -7
- package/dist/ensure-playwright.d.ts +4 -0
- package/dist/ensure-playwright.js +7 -2
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +83 -7
- package/dist/explore.js +326 -77
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +19 -10
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +41 -6
- package/dist/json-out.d.ts +53 -0
- package/dist/json-out.js +91 -0
- package/dist/keys-json.d.ts +1 -0
- package/dist/keys-json.js +71 -0
- package/dist/login.js +27 -11
- package/dist/lumi.d.ts +104 -0
- package/dist/lumi.js +364 -0
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +521 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +4 -2
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +38 -16
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +126 -62
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +1 -0
- package/dist/report-md.js +1 -1
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.js +356 -2
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +101 -17
- package/dist/signing.js +13 -0
- package/dist/sim.js +7 -3
- package/dist/smoke.d.ts +28 -0
- package/dist/smoke.js +117 -0
- package/dist/spawn-bin.d.ts +11 -0
- package/dist/spawn-bin.js +21 -0
- package/dist/status.d.ts +26 -4
- package/dist/status.js +69 -5
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +81 -18
- package/package.json +4 -3
- package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/run-net.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { hostAllowed, isLoopback, originAllowed, tokenMatches } from "./run-protocol.js";
|
|
3
|
+
export const NET_PATH = "/__shipvo/net";
|
|
4
|
+
/** One cookie jar per phone session, like the cookies of one installed app. */
|
|
5
|
+
export class CookieJar {
|
|
6
|
+
list = [];
|
|
7
|
+
set(url, header, now = Date.now()) {
|
|
8
|
+
const [pair, ...attrs] = header.split(";").map((x) => x.trim());
|
|
9
|
+
const eq = pair.indexOf("=");
|
|
10
|
+
if (eq < 1)
|
|
11
|
+
return;
|
|
12
|
+
const c = { name: pair.slice(0, eq), value: pair.slice(eq + 1), domain: url.hostname, hostOnly: true, path: defaultPath(url.pathname), secure: false };
|
|
13
|
+
for (const a of attrs) {
|
|
14
|
+
const i = a.indexOf("=");
|
|
15
|
+
const k = (i < 0 ? a : a.slice(0, i)).toLowerCase();
|
|
16
|
+
const v = i < 0 ? "" : a.slice(i + 1);
|
|
17
|
+
if (k === "domain" && v) {
|
|
18
|
+
c.domain = v.replace(/^\./, "").toLowerCase();
|
|
19
|
+
c.hostOnly = false;
|
|
20
|
+
}
|
|
21
|
+
else if (k === "path" && v.startsWith("/"))
|
|
22
|
+
c.path = v;
|
|
23
|
+
else if (k === "secure")
|
|
24
|
+
c.secure = true;
|
|
25
|
+
else if (k === "expires" && c.expires === undefined) {
|
|
26
|
+
const t = Date.parse(v);
|
|
27
|
+
if (!Number.isNaN(t))
|
|
28
|
+
c.expires = t;
|
|
29
|
+
}
|
|
30
|
+
else if (k === "max-age" && /^-?\d+$/.test(v))
|
|
31
|
+
c.expires = now + Number(v) * 1000;
|
|
32
|
+
}
|
|
33
|
+
if (!c.hostOnly && !(url.hostname === c.domain || url.hostname.endsWith("." + c.domain)))
|
|
34
|
+
return;
|
|
35
|
+
this.list = this.list.filter((o) => !(o.name === c.name && o.domain === c.domain && o.path === c.path));
|
|
36
|
+
if (c.expires === undefined || c.expires > now)
|
|
37
|
+
this.list.push(c);
|
|
38
|
+
}
|
|
39
|
+
header(url, now = Date.now()) {
|
|
40
|
+
this.list = this.list.filter((c) => c.expires === undefined || c.expires > now);
|
|
41
|
+
return this.list
|
|
42
|
+
.filter((c) => (c.hostOnly ? url.hostname === c.domain : url.hostname === c.domain || url.hostname.endsWith("." + c.domain)) && url.pathname.startsWith(c.path) && (!c.secure || url.protocol === "https:"))
|
|
43
|
+
.sort((a, b) => b.path.length - a.path.length)
|
|
44
|
+
.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const defaultPath = (p) => { const i = p.lastIndexOf("/"); return i <= 0 ? "/" : p.slice(0, i); };
|
|
48
|
+
const FALLBACK = {
|
|
49
|
+
online: { latencyMs: 0, downKbps: Infinity, failureRate: 0 }, offline: { latencyMs: 0, downKbps: 0, failureRate: 1 },
|
|
50
|
+
"2g": { latencyMs: 800, downKbps: 50, failureRate: 0.02 }, "3g": { latencyMs: 300, downKbps: 750, failureRate: 0.01 },
|
|
51
|
+
"slow-4g": { latencyMs: 150, downKbps: 1600, failureRate: 0 }, spotty: { latencyMs: 400, downKbps: 500, failureRate: 0.3 },
|
|
52
|
+
"low-end-4g": { latencyMs: 100, downKbps: 6300, failureRate: 0.005 },
|
|
53
|
+
};
|
|
54
|
+
/** The profile table from @shipvo/sim (one source of truth); a built-in copy if that file cannot be loaded. */
|
|
55
|
+
export async function loadProfiles() {
|
|
56
|
+
try {
|
|
57
|
+
const file = createRequire(import.meta.url).resolve("@shipvo/sim/network-profiles.mjs");
|
|
58
|
+
const mod = await import(new URL(`file://${file.startsWith("/") ? "" : "/"}${file.replace(/\\/g, "/")}`).href);
|
|
59
|
+
return mod.PROFILES;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return FALLBACK;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const HOP = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "te", "trailer", "content-length", "content-encoding", "set-cookie", "host"]);
|
|
66
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
const readBody = (req) => new Promise((ok, fail) => { const b = []; req.on("data", (c) => b.push(c)); req.on("end", () => ok(Buffer.concat(b))); req.on("error", fail); });
|
|
68
|
+
const hostOf = (u) => u.host;
|
|
69
|
+
/** The request handler for `NET_PATH`. `tokens` are the credentials it accepts; `port()` is the bound port. */
|
|
70
|
+
export function createNetProxy(o) {
|
|
71
|
+
const jars = new Map();
|
|
72
|
+
const log = [];
|
|
73
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
74
|
+
let draw = 1;
|
|
75
|
+
const random = () => { draw = (Math.imul(draw, 1664525) + 1013904223) >>> 0; return draw / 4294967296; };
|
|
76
|
+
const refuse = (res, code, msg) => { res.writeHead(code, { "content-type": "text/plain" }); res.end(msg); };
|
|
77
|
+
async function handle(req, res) {
|
|
78
|
+
const port = o.port();
|
|
79
|
+
if (!isLoopback(req.socket.remoteAddress) || !hostAllowed(req.headers.host, port) || req.headers.origin === undefined || !originAllowed(req.headers.origin, port))
|
|
80
|
+
return refuse(res, 403, "Forbidden");
|
|
81
|
+
const given = req.headers["x-shipvo-token"];
|
|
82
|
+
if (typeof given !== "string" || !o.tokens.some((t) => tokenMatches(given, t)))
|
|
83
|
+
return refuse(res, 401, "Unauthorized");
|
|
84
|
+
if (req.method !== "POST")
|
|
85
|
+
return refuse(res, 405, "Use POST");
|
|
86
|
+
let spec;
|
|
87
|
+
try {
|
|
88
|
+
spec = JSON.parse(decodeURIComponent(String(req.headers["x-shipvo-req"])));
|
|
89
|
+
new URL(spec.url);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return refuse(res, 400, "Bad request description");
|
|
93
|
+
}
|
|
94
|
+
const url = new URL(spec.url);
|
|
95
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
96
|
+
return refuse(res, 400, "Only http and https");
|
|
97
|
+
const session = String(req.headers["x-shipvo-session"] ?? "default").slice(0, 64);
|
|
98
|
+
const profileName = String(req.headers["x-shipvo-profile"] ?? "online");
|
|
99
|
+
const profile = o.profiles[profileName] ?? o.profiles.online;
|
|
100
|
+
const method = String(spec.method || "GET").toUpperCase();
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
const record = (status, error) => { const r = { at: started, session, method, host: hostOf(url), status, ms: Date.now() - started, profile: profileName, ...(error ? { error } : {}) }; log.push(r); o.onRecord?.(r); };
|
|
103
|
+
const body = await readBody(req);
|
|
104
|
+
const failed = profile.failureRate >= 1 || random() < profile.failureRate;
|
|
105
|
+
const bytesMs = (n) => (n && Number.isFinite(profile.downKbps) && profile.downKbps > 0 ? (n * 8) / profile.downKbps : 0);
|
|
106
|
+
await sleep(profile.latencyMs + bytesMs(body.length));
|
|
107
|
+
// XMLHttpRequest cannot tell a 599 from a reply, so drop the connection and let the browser raise its own `error` event.
|
|
108
|
+
const dropForXhr = () => { if (req.headers["x-shipvo-xhr"]) {
|
|
109
|
+
req.socket.destroy();
|
|
110
|
+
return true;
|
|
111
|
+
} return false; };
|
|
112
|
+
if (failed) {
|
|
113
|
+
record(599, "network request failed");
|
|
114
|
+
if (dropForXhr())
|
|
115
|
+
return;
|
|
116
|
+
res.writeHead(599, { "x-shipvo-net-error": "1", "content-type": "text/plain" });
|
|
117
|
+
return void res.end("network request failed");
|
|
118
|
+
}
|
|
119
|
+
let jar = jars.get(session);
|
|
120
|
+
if (!jar)
|
|
121
|
+
jars.set(session, jar = new CookieJar());
|
|
122
|
+
const headers = new Headers();
|
|
123
|
+
for (const [k, v] of Object.entries(spec.headers ?? {}))
|
|
124
|
+
if (!HOP.has(k.toLowerCase()) && !["cookie", "origin", "referer"].includes(k.toLowerCase()))
|
|
125
|
+
headers.set(k, v);
|
|
126
|
+
let up;
|
|
127
|
+
let cur = url;
|
|
128
|
+
let curMethod = method;
|
|
129
|
+
let curBody = method === "GET" || method === "HEAD" || body.length === 0 ? undefined : new Uint8Array(body);
|
|
130
|
+
try {
|
|
131
|
+
// Redirects are followed here so each hop's Set-Cookie reaches the jar and its cookies go out on the next hop.
|
|
132
|
+
for (let hop = 0;; hop++) {
|
|
133
|
+
const h = new Headers(headers);
|
|
134
|
+
h.delete("cookie");
|
|
135
|
+
const c = jar.header(cur);
|
|
136
|
+
if (c)
|
|
137
|
+
h.set("cookie", c);
|
|
138
|
+
up = await doFetch(cur, { method: curMethod, headers: h, body: curBody, redirect: "manual" });
|
|
139
|
+
for (const sc of up.headers.getSetCookie())
|
|
140
|
+
jar.set(cur, sc);
|
|
141
|
+
const loc = up.headers.get("location");
|
|
142
|
+
if (![301, 302, 303, 307, 308].includes(up.status) || !loc)
|
|
143
|
+
break;
|
|
144
|
+
if (hop >= 20)
|
|
145
|
+
throw new Error("too many redirects");
|
|
146
|
+
const next = new URL(loc, cur);
|
|
147
|
+
if (next.protocol !== "http:" && next.protocol !== "https:")
|
|
148
|
+
throw new Error("redirect to an unsupported address");
|
|
149
|
+
void up.body?.cancel().catch(() => { });
|
|
150
|
+
if (next.origin !== cur.origin)
|
|
151
|
+
headers.delete("authorization");
|
|
152
|
+
if (up.status === 303 ? curMethod !== "HEAD" : (up.status === 301 || up.status === 302) && curMethod === "POST") {
|
|
153
|
+
curMethod = "GET";
|
|
154
|
+
curBody = undefined;
|
|
155
|
+
}
|
|
156
|
+
cur = next;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
record(599, e.message);
|
|
161
|
+
if (dropForXhr())
|
|
162
|
+
return;
|
|
163
|
+
res.writeHead(599, { "x-shipvo-net-error": "1", "content-type": "text/plain" });
|
|
164
|
+
return void res.end(`network request failed: ${e.message}`);
|
|
165
|
+
}
|
|
166
|
+
const out = { "x-shipvo-final-url": cur.href, "cache-control": "no-store" };
|
|
167
|
+
up.headers.forEach((v, k) => { if (!HOP.has(k))
|
|
168
|
+
out[k] = v; });
|
|
169
|
+
res.writeHead(up.status, out);
|
|
170
|
+
record(up.status);
|
|
171
|
+
if (!up.body || method === "HEAD")
|
|
172
|
+
return void res.end();
|
|
173
|
+
res.on("close", () => { void up.body?.cancel().catch(() => { }); });
|
|
174
|
+
const reader = up.body.getReader();
|
|
175
|
+
for (;;) {
|
|
176
|
+
const { done, value } = await reader.read();
|
|
177
|
+
if (done)
|
|
178
|
+
break;
|
|
179
|
+
const wait = bytesMs(value.length);
|
|
180
|
+
if (wait > 0)
|
|
181
|
+
await sleep(wait);
|
|
182
|
+
if (!res.write(value))
|
|
183
|
+
await new Promise((r) => res.once("drain", r));
|
|
184
|
+
}
|
|
185
|
+
res.end();
|
|
186
|
+
}
|
|
187
|
+
return { handle: (req, res) => handle(req, res).catch(() => { if (!res.headersSent)
|
|
188
|
+
refuse(res, 500, "Proxy error");
|
|
189
|
+
else
|
|
190
|
+
res.destroy(); }), log };
|
|
191
|
+
}
|
|
192
|
+
/** The script that runs inside the app's page: sends cross-origin fetch and XMLHttpRequest through `NET_PATH`. */
|
|
193
|
+
export function netClientSource() { return `(${netClientMain.toString()})`; }
|
|
194
|
+
function netClientMain(win, cfg) {
|
|
195
|
+
const origin = win.location.origin;
|
|
196
|
+
const abs = (u) => new win.URL(u, win.location.href);
|
|
197
|
+
const proxied = (u) => { try {
|
|
198
|
+
const x = abs(String(u));
|
|
199
|
+
return (x.protocol === "http:" || x.protocol === "https:") && x.origin !== origin ? x : null;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
} };
|
|
204
|
+
const meta = (url, method, headers) => ({
|
|
205
|
+
"x-shipvo-token": cfg.token, "x-shipvo-session": cfg.session, "x-shipvo-profile": cfg.profile(),
|
|
206
|
+
"x-shipvo-req": encodeURIComponent(JSON.stringify({ url: url.href, method, headers })),
|
|
207
|
+
});
|
|
208
|
+
const origFetch = win.fetch.bind(win);
|
|
209
|
+
win.fetch = async function (input, init) {
|
|
210
|
+
const isReq = input instanceof win.Request;
|
|
211
|
+
// Decide before building a Request: building one from a Request with a body would use up the original.
|
|
212
|
+
if (!proxied(isReq ? input.url : input))
|
|
213
|
+
return origFetch(input, init);
|
|
214
|
+
const req = new win.Request(input, init);
|
|
215
|
+
const url = proxied(req.url);
|
|
216
|
+
if (!url)
|
|
217
|
+
return origFetch(input, init);
|
|
218
|
+
const t0 = Date.now();
|
|
219
|
+
const headers = {};
|
|
220
|
+
req.headers.forEach((v, k) => { headers[k] = v; });
|
|
221
|
+
const fail = () => { cfg.log({ method: req.method, host: url.host, status: 0, ms: Date.now() - t0 }); throw new TypeError("Network request failed"); };
|
|
222
|
+
if (!cfg.online())
|
|
223
|
+
fail();
|
|
224
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
|
225
|
+
let res;
|
|
226
|
+
try {
|
|
227
|
+
res = await origFetch("/__shipvo/net", { method: "POST", headers: meta(url, req.method, headers), body, signal: req.signal, cache: "no-store" });
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
fail();
|
|
231
|
+
}
|
|
232
|
+
if (res.headers.get("x-shipvo-net-error"))
|
|
233
|
+
fail();
|
|
234
|
+
cfg.log({ method: req.method, host: url.host, status: res.status, ms: Date.now() - t0 });
|
|
235
|
+
return res;
|
|
236
|
+
};
|
|
237
|
+
const X = win.XMLHttpRequest;
|
|
238
|
+
if (X) {
|
|
239
|
+
const open = X.prototype.open, send = X.prototype.send, setH = X.prototype.setRequestHeader;
|
|
240
|
+
X.prototype.open = function (method, u, ...rest) {
|
|
241
|
+
const url = proxied(u);
|
|
242
|
+
this.__net = url ? { url, method: String(method).toUpperCase(), headers: {} } : undefined;
|
|
243
|
+
return this.__net ? open.call(this, "POST", "/__shipvo/net", ...rest) : open.call(this, method, u, ...rest);
|
|
244
|
+
};
|
|
245
|
+
X.prototype.setRequestHeader = function (k, v) { if (this.__net) {
|
|
246
|
+
this.__net.headers[k] = v;
|
|
247
|
+
return;
|
|
248
|
+
} return setH.call(this, k, v); };
|
|
249
|
+
X.prototype.send = function (body) {
|
|
250
|
+
const n = this.__net;
|
|
251
|
+
if (!n)
|
|
252
|
+
return send.call(this, body);
|
|
253
|
+
const t0 = Date.now();
|
|
254
|
+
if (!cfg.online()) {
|
|
255
|
+
cfg.log({ method: n.method, host: n.url.host, status: 0, ms: 0 });
|
|
256
|
+
win.setTimeout(() => { this.dispatchEvent(new win.ProgressEvent("error")); }, 0);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const m = { ...meta(n.url, n.method, n.headers), "x-shipvo-xhr": "1" };
|
|
260
|
+
for (const k of Object.keys(m))
|
|
261
|
+
setH.call(this, k, m[k]);
|
|
262
|
+
this.addEventListener("loadend", () => cfg.log({ method: n.method, host: n.url.host, status: this.status, ms: Date.now() - t0 }));
|
|
263
|
+
return send.call(this, n.method === "GET" || n.method === "HEAD" ? null : body);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
package/dist/run.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
1
2
|
import { type Server } from "node:http";
|
|
3
|
+
import { type NetRecord } from "./run-net.js";
|
|
2
4
|
export type Platform = "android" | "ios";
|
|
3
5
|
export type RunOptions = {
|
|
4
6
|
platform: Platform;
|
|
5
7
|
open: boolean;
|
|
6
8
|
port?: number;
|
|
7
9
|
native: boolean;
|
|
10
|
+
lan: boolean;
|
|
8
11
|
};
|
|
12
|
+
export declare const RUN_USAGE = "Usage: shipvo run android|ios [options]\n\nOptions:\n --no-open do not open the browser\n --port <n> serve on this port (default: a free port)\n --native run on a real emulator or simulator with `npx expo run:<platform>`\n\nExample: shipvo run android";
|
|
13
|
+
/** True when this project has no earlier web build, so Metro starts with an empty cache. */
|
|
14
|
+
export declare const isFirstBuild: (root: string) => boolean;
|
|
9
15
|
/** Parses `run` arguments. Throws a plain message for anything it does not know. */
|
|
10
16
|
export declare function parseRunArgs(argv: string[]): RunOptions;
|
|
11
17
|
/** The command `--native` runs. */
|
|
@@ -42,6 +48,8 @@ export type RenderFrame = ((o: {
|
|
|
42
48
|
dark?: boolean;
|
|
43
49
|
status?: object;
|
|
44
50
|
screen?: string;
|
|
51
|
+
lumi?: boolean;
|
|
52
|
+
guide?: boolean;
|
|
45
53
|
}) => {
|
|
46
54
|
width: number;
|
|
47
55
|
height: number;
|
|
@@ -57,6 +65,12 @@ export type RenderFrame = ((o: {
|
|
|
57
65
|
export declare function loadFrameRenderer(bases?: string[]): Promise<RenderFrame>;
|
|
58
66
|
/** The page that shows the app in an Android or iOS frame at its real logical size, scaled to fit the window. The phone body comes from @shipvo/sim's renderFrame. */
|
|
59
67
|
export declare function runFramePage(platform: Platform, name: string, render: RenderFrame, dark?: boolean, token?: string): string;
|
|
68
|
+
/** True for a phone or tablet browser, by user agent. */
|
|
69
|
+
export declare const isMobileUserAgent: (ua: string | undefined) => boolean;
|
|
70
|
+
/** The page for a real phone's browser: the app fills the screen, no phone frame. */
|
|
71
|
+
export declare function runBarePage(name: string, dark?: boolean): string;
|
|
72
|
+
/** The first private IPv4 address of this machine (or any non-internal one), or undefined. */
|
|
73
|
+
export declare function lanAddress(ifaces?: ReturnType<typeof networkInterfaces>): string | undefined;
|
|
60
74
|
/** Adds `.shipvo/` to `<dir>/.gitignore` when it is not already ignored. */
|
|
61
75
|
export declare function ensureShipvoIgnored(dir: string): void;
|
|
62
76
|
/**
|
|
@@ -69,18 +83,27 @@ export declare function startRunServer(opts: {
|
|
|
69
83
|
dir: string;
|
|
70
84
|
platform: Platform;
|
|
71
85
|
name: string;
|
|
86
|
+
appId?: string;
|
|
72
87
|
port?: number;
|
|
73
88
|
projectDir?: string;
|
|
74
89
|
render?: RenderFrame;
|
|
90
|
+
lan?: {
|
|
91
|
+
host: string;
|
|
92
|
+
};
|
|
93
|
+
onNet?: (r: NetRecord) => void;
|
|
94
|
+
fetchImpl?: typeof fetch;
|
|
75
95
|
}): Promise<{
|
|
76
96
|
server: Server;
|
|
77
97
|
port: number;
|
|
78
98
|
token: string;
|
|
99
|
+
lanToken?: string;
|
|
100
|
+
netLog: NetRecord[];
|
|
79
101
|
close: () => Promise<void>;
|
|
80
102
|
}>;
|
|
81
103
|
/** Names the module or file a web build failed on, from the build's error output. */
|
|
82
104
|
export declare function describeWebBuildFailure(output: string): string;
|
|
83
105
|
/** Builds the web export with `rebuildInto` from sim.ts (the same build as `shipvo sim`) into `<root>/sim-dist`. */
|
|
84
106
|
export declare function buildWeb(root: string, command?: string): Promise<void>;
|
|
107
|
+
export declare function openBrowser(url: string): void;
|
|
85
108
|
/** Returns the exit code. */
|
|
86
109
|
export declare function run(argv: string[]): Promise<number>;
|
package/dist/run.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// open a phone frame page in the default browser. No browser automation. `--native` runs `npx expo run:<platform>`.
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { networkInterfaces } from "node:os";
|
|
5
6
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { createServer } from "node:http";
|
|
7
8
|
import { createRequire } from "node:module";
|
|
@@ -10,14 +11,28 @@ import { join, extname, normalize, resolve, sep } from "node:path";
|
|
|
10
11
|
import { loadConfig } from "./config.js";
|
|
11
12
|
import { AGENT_PATH, hostAllowed, isAgentRole, isLoopback, originAllowed, tokenMatches } from "./run-protocol.js";
|
|
12
13
|
import { bridgeScript } from "./run-bridge.js";
|
|
14
|
+
import { NET_PATH, createNetProxy, loadProfiles, netClientSource } from "./run-net.js";
|
|
13
15
|
import { acceptWebSocket } from "./run-ws.js";
|
|
16
|
+
import { qrMatrix, qrToTerminal } from "./qr.js";
|
|
17
|
+
import { spawnBin } from "./spawn-bin.js";
|
|
14
18
|
import { portFree, rebuildInto } from "./sim.js";
|
|
19
|
+
export const RUN_USAGE = `Usage: shipvo run android|ios [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--no-open do not open the browser
|
|
23
|
+
--port <n> serve on this port (default: a free port)
|
|
24
|
+
--native run on a real emulator or simulator with \`npx expo run:<platform>\`
|
|
25
|
+
|
|
26
|
+
Example: shipvo run android`;
|
|
27
|
+
/** True when this project has no earlier web build, so Metro starts with an empty cache. */
|
|
28
|
+
export const isFirstBuild = (root) => !existsSync(join(root, "sim-dist"));
|
|
15
29
|
/** Parses `run` arguments. Throws a plain message for anything it does not know. */
|
|
16
30
|
export function parseRunArgs(argv) {
|
|
17
31
|
let platform;
|
|
18
32
|
let open = true;
|
|
19
33
|
let port;
|
|
20
34
|
let native = false;
|
|
35
|
+
let lan = false;
|
|
21
36
|
for (let i = 0; i < argv.length; i++) {
|
|
22
37
|
const a = argv[i];
|
|
23
38
|
if (a === "android" || a === "ios")
|
|
@@ -26,6 +41,8 @@ export function parseRunArgs(argv) {
|
|
|
26
41
|
open = false;
|
|
27
42
|
else if (a === "--native")
|
|
28
43
|
native = true;
|
|
44
|
+
else if (a === "--lan")
|
|
45
|
+
lan = true;
|
|
29
46
|
else if (a === "--port") {
|
|
30
47
|
const v = argv[++i];
|
|
31
48
|
if (!v || !/^\d+$/.test(v) || Number(v) > 65535)
|
|
@@ -33,11 +50,13 @@ export function parseRunArgs(argv) {
|
|
|
33
50
|
port = Number(v);
|
|
34
51
|
}
|
|
35
52
|
else
|
|
36
|
-
throw new Error(`Unknown argument "${a}". Usage: shipvo run android|ios [--no-open] [--port <n>] [--native]`);
|
|
53
|
+
throw new Error(`Unknown argument "${a}". Usage: shipvo run android|ios [--no-open] [--port <n>] [--native] [--lan]`);
|
|
37
54
|
}
|
|
38
55
|
if (!platform)
|
|
39
|
-
throw new Error("Say which phone: shipvo run android or shipvo run ios.");
|
|
40
|
-
|
|
56
|
+
throw new Error("Say which phone: shipvo run android or shipvo run ios. Try: shipvo run android");
|
|
57
|
+
if (lan && native)
|
|
58
|
+
throw new Error("--lan serves the simulated phone; it cannot be combined with --native.");
|
|
59
|
+
return { platform, open, port, native, lan };
|
|
41
60
|
}
|
|
42
61
|
/** The command `--native` runs. */
|
|
43
62
|
export const nativeCommand = (p) => ({ cmd: "npx", args: ["expo", `run:${p}`] });
|
|
@@ -68,7 +87,7 @@ export function runFramePage(platform, name, render, dark = false, token) {
|
|
|
68
87
|
const f = FRAMES[platform];
|
|
69
88
|
const title = name.replace(/[<>&"']/g, "");
|
|
70
89
|
const sheet = `<div class="sheet" id="sheet" role="dialog" aria-label="${f.label} system sheet" data-part="system-sheet"><div class="box"><p>${f.sheet.title}</p><button type="button" data-act="allow">${f.sheet.allow}</button><button type="button" data-act="deny">${f.sheet.deny}</button></div></div>`;
|
|
71
|
-
const frame = render({ skin: platform, dark, screen: `<iframe id="app" src="/app/" title="${title}"></iframe>${sheet}` });
|
|
90
|
+
const frame = render({ skin: platform, dark, guide: true, screen: `<iframe id="app" src="/app/" title="${title}"></iframe>${sheet}` });
|
|
72
91
|
return `<!doctype html>
|
|
73
92
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
74
93
|
<title>${title} on ${f.label} (simulated)</title>
|
|
@@ -96,6 +115,23 @@ function fit(){var s=Math.min(1,(innerWidth-24)/${frame.outerWidth},(innerHeight
|
|
|
96
115
|
addEventListener("resize",fit);fit();
|
|
97
116
|
</script>${render.statusScript ? `<script>${render.statusScript("app", platform).replace(/<\/script/gi, "<\\/script")}</script>` : ""}${token ? `<script src="/__shipvo/bridge.js" data-token="${token}"></script>` : ""}</body></html>`;
|
|
98
117
|
}
|
|
118
|
+
/** True for a phone or tablet browser, by user agent. */
|
|
119
|
+
export const isMobileUserAgent = (ua) => /Android|iPhone|iPad|iPod|Mobile/i.test(ua ?? "");
|
|
120
|
+
/** The page for a real phone's browser: the app fills the screen, no phone frame. */
|
|
121
|
+
export function runBarePage(name, dark = false) {
|
|
122
|
+
const title = name.replace(/[<>&"']/g, "");
|
|
123
|
+
return `<!doctype html>
|
|
124
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
125
|
+
<title>${title}</title>
|
|
126
|
+
<style>html,body{margin:0;height:100%;overflow:hidden;background:${dark ? "#15171a" : "#fff"}}iframe{position:fixed;inset:0;width:100%;height:100%;border:0}</style></head><body>
|
|
127
|
+
<iframe id="app" src="/app/" title="${title}" data-part="bare-app"></iframe>
|
|
128
|
+
</body></html>`;
|
|
129
|
+
}
|
|
130
|
+
/** The first private IPv4 address of this machine (or any non-internal one), or undefined. */
|
|
131
|
+
export function lanAddress(ifaces = networkInterfaces()) {
|
|
132
|
+
const all = Object.values(ifaces).flatMap((l) => l ?? []).filter((i) => i.family === "IPv4" && !i.internal).map((i) => i.address);
|
|
133
|
+
return all.find((a) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a)) ?? all[0];
|
|
134
|
+
}
|
|
99
135
|
const TYPES = {
|
|
100
136
|
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
101
137
|
".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon",
|
|
@@ -123,12 +159,27 @@ export async function startRunServer(opts) {
|
|
|
123
159
|
const render = opts.render ?? await loadFrameRenderer();
|
|
124
160
|
const pages = { light: runFramePage(opts.platform, opts.name, render, false, pageToken), dark: runFramePage(opts.platform, opts.name, render, true, pageToken) };
|
|
125
161
|
const bridge = bridgeScript();
|
|
162
|
+
// With --lan every request needs this token, in `?t=` or in the cookie the first valid request sets.
|
|
163
|
+
const lanToken = opts.lan ? randomBytes(16).toString("hex") : undefined;
|
|
126
164
|
let boundPort = -1; // set once the server listens; Host and Origin must match it
|
|
165
|
+
// App requests to other origins go through /__shipvo/net, so the browser applies no CORS (see run-net.ts). Only the page's token opens it.
|
|
166
|
+
const net = createNetProxy({ tokens: [pageToken, token], port: () => boundPort, profiles: await loadProfiles(), onRecord: opts.onNet, fetchImpl: opts.fetchImpl });
|
|
167
|
+
const netSnippet = `<script>(${netClientSource()})(window,{token:${JSON.stringify(pageToken)},session:(function(){try{var k="shipvo_net_session";return sessionStorage.getItem(k)||(sessionStorage.setItem(k,crypto.randomUUID()),sessionStorage.getItem(k))}catch(e){return "tab"}})(),profile:function(){return window.__pnNet?window.__pnNet.profile():"online"},online:function(){return window.__pnNet?window.__pnNet.online():true},log:function(d){if(window.__pnNet)window.__pnNet.log(d)}})</script>`;
|
|
127
168
|
const server = createServer((req, res) => {
|
|
128
|
-
if (!hostAllowed(req.headers.host, boundPort)) {
|
|
169
|
+
if (!hostAllowed(req.headers.host, boundPort) && !(opts.lan && req.headers.host === `${opts.lan.host}:${boundPort}`)) {
|
|
129
170
|
res.writeHead(403);
|
|
130
171
|
return void res.end("Forbidden");
|
|
131
172
|
}
|
|
173
|
+
if (lanToken) {
|
|
174
|
+
const given = new URL(req.url ?? "/", "http://x").searchParams.get("t");
|
|
175
|
+
const cookie = /(?:^|;\s*)shipvo_t=([0-9a-f]+)/.exec(req.headers.cookie ?? "")?.[1] ?? null;
|
|
176
|
+
if (tokenMatches(given, lanToken))
|
|
177
|
+
res.setHeader("set-cookie", `shipvo_t=${lanToken}; Path=/; HttpOnly; SameSite=Lax`);
|
|
178
|
+
else if (!tokenMatches(cookie, lanToken)) {
|
|
179
|
+
res.writeHead(403);
|
|
180
|
+
return void res.end("Forbidden: open the address shipvo run printed, it has the token.");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
132
183
|
let path;
|
|
133
184
|
try {
|
|
134
185
|
path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
@@ -143,9 +194,12 @@ export async function startRunServer(opts) {
|
|
|
143
194
|
}
|
|
144
195
|
if (path === "/" || path === "/index.html") {
|
|
145
196
|
const dark = new URL(req.url ?? "/", "http://x").searchParams.get("dark") === "1";
|
|
146
|
-
|
|
147
|
-
|
|
197
|
+
const bare = isMobileUserAgent(req.headers["user-agent"]);
|
|
198
|
+
res.writeHead(200, { "content-type": TYPES[".html"], vary: "user-agent" });
|
|
199
|
+
return void res.end(bare ? runBarePage(opts.name, dark) : dark ? pages.dark : pages.light);
|
|
148
200
|
}
|
|
201
|
+
if (path === NET_PATH)
|
|
202
|
+
return void net.handle(req, res);
|
|
149
203
|
if (path === "/__shipvo/bridge.js") {
|
|
150
204
|
res.writeHead(200, { "content-type": TYPES[".js"], "cache-control": "no-store" });
|
|
151
205
|
return void res.end(bridge);
|
|
@@ -168,7 +222,14 @@ export async function startRunServer(opts) {
|
|
|
168
222
|
}
|
|
169
223
|
target = spa;
|
|
170
224
|
}
|
|
171
|
-
|
|
225
|
+
const type = TYPES[extname(target).toLowerCase()] ?? "application/octet-stream";
|
|
226
|
+
res.writeHead(200, { "content-type": type });
|
|
227
|
+
if (extname(target).toLowerCase() === ".html") {
|
|
228
|
+
const html = readFileSync(target, "utf8"); // first script in the page, so it wraps fetch before the app's code runs
|
|
229
|
+
// A real phone (not loopback) cannot use the route, which only serves the local page: leave its requests direct.
|
|
230
|
+
if (isLoopback(req.socket.remoteAddress) && /<head[^>]*>/i.test(html))
|
|
231
|
+
return void res.end(html.replace(/<head[^>]*>/i, (m) => m + netSnippet));
|
|
232
|
+
}
|
|
172
233
|
res.end(readFileSync(target));
|
|
173
234
|
});
|
|
174
235
|
const peers = {};
|
|
@@ -210,15 +271,15 @@ export async function startRunServer(opts) {
|
|
|
210
271
|
rmSync(infoPath, { force: true }); };
|
|
211
272
|
return new Promise((ok, fail) => {
|
|
212
273
|
server.once("error", fail);
|
|
213
|
-
server.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
274
|
+
server.listen(opts.port ?? 0, opts.lan ? "0.0.0.0" : "127.0.0.1", () => {
|
|
214
275
|
const port = server.address().port;
|
|
215
276
|
boundPort = port;
|
|
216
277
|
if (infoPath) {
|
|
217
|
-
const info = { port, platform: opts.platform, app: opts.name, token, pid: process.pid };
|
|
278
|
+
const info = { port, platform: opts.platform, app: opts.appId ?? opts.name, token, pid: process.pid };
|
|
218
279
|
mkdirSync(join(infoPath, ".."), { recursive: true });
|
|
219
280
|
writeFileSync(infoPath, JSON.stringify(info, null, 2) + "\n", { mode: 0o600 });
|
|
220
281
|
}
|
|
221
|
-
ok({ server, port, token, close: () => new Promise((r) => { cleanup(); for (const p of Object.values(peers))
|
|
282
|
+
ok({ server, port, token, lanToken, netLog: net.log, close: () => new Promise((r) => { cleanup(); for (const p of Object.values(peers))
|
|
222
283
|
p?.close(); server.close(() => r()); server.closeAllConnections?.(); }) });
|
|
223
284
|
});
|
|
224
285
|
});
|
|
@@ -243,7 +304,7 @@ export async function buildWeb(root, command) {
|
|
|
243
304
|
throw new Error(describeWebBuildFailure(e.output ?? e.message));
|
|
244
305
|
}
|
|
245
306
|
}
|
|
246
|
-
function openBrowser(url) {
|
|
307
|
+
export function openBrowser(url) {
|
|
247
308
|
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
248
309
|
const p = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
249
310
|
p.on("error", () => console.error(`Could not open a browser. Open ${url} yourself.`));
|
|
@@ -251,6 +312,10 @@ function openBrowser(url) {
|
|
|
251
312
|
}
|
|
252
313
|
/** Returns the exit code. */
|
|
253
314
|
export async function run(argv) {
|
|
315
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
316
|
+
console.log(RUN_USAGE);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
254
319
|
let o;
|
|
255
320
|
try {
|
|
256
321
|
o = parseRunArgs(argv);
|
|
@@ -262,11 +327,14 @@ export async function run(argv) {
|
|
|
262
327
|
if (o.native) {
|
|
263
328
|
const { cmd, args } = nativeCommand(o.platform);
|
|
264
329
|
console.error(`Running natively: ${cmd} ${args.join(" ")} (not the simulated device)`);
|
|
265
|
-
|
|
330
|
+
const call = spawnBin(cmd, args);
|
|
331
|
+
return spawnSync(call.command, call.args, { stdio: "inherit", shell: call.shell }).status ?? 1;
|
|
266
332
|
}
|
|
267
|
-
let name;
|
|
333
|
+
let name, appId;
|
|
268
334
|
try {
|
|
269
|
-
|
|
335
|
+
const c = loadConfig();
|
|
336
|
+
name = c.name;
|
|
337
|
+
appId = c.appId;
|
|
270
338
|
}
|
|
271
339
|
catch (e) {
|
|
272
340
|
console.error(e.message);
|
|
@@ -277,6 +345,8 @@ export async function run(argv) {
|
|
|
277
345
|
return 1;
|
|
278
346
|
}
|
|
279
347
|
const out = join(process.cwd(), "sim-dist");
|
|
348
|
+
if (isFirstBuild(process.cwd()))
|
|
349
|
+
console.error("First build, this can take a minute.");
|
|
280
350
|
console.error(`Building ${name} for web with the simulated device…`);
|
|
281
351
|
try {
|
|
282
352
|
await buildWeb(process.cwd());
|
|
@@ -289,8 +359,22 @@ export async function run(argv) {
|
|
|
289
359
|
ensureShipvoIgnored(process.cwd());
|
|
290
360
|
}
|
|
291
361
|
catch { /* read-only project: the session still works */ }
|
|
292
|
-
|
|
293
|
-
|
|
362
|
+
let lan;
|
|
363
|
+
if (o.lan) {
|
|
364
|
+
const host = lanAddress();
|
|
365
|
+
if (!host) {
|
|
366
|
+
console.error("--lan needs a network address and this computer has none. Connect to Wi-Fi and try again.");
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
lan = { host };
|
|
370
|
+
}
|
|
371
|
+
const s = await startRunServer({ dir: out, platform: o.platform, name, appId, port: o.port, projectDir: process.cwd(), lan });
|
|
372
|
+
const url = `http://127.0.0.1:${s.port}/${s.lanToken ? `?t=${s.lanToken}` : ""}`;
|
|
373
|
+
if (lan && s.lanToken) {
|
|
374
|
+
const lanUrl = `http://${lan.host}:${s.port}/?t=${s.lanToken}`;
|
|
375
|
+
console.error(`\nOpen this on your phone (same Wi-Fi):\n${lanUrl}\n\n${qrToTerminal(qrMatrix(lanUrl))}\n`);
|
|
376
|
+
console.error("Warning: anyone on this network who has this address can open the page. The token in it is the only lock. Press Ctrl+C to stop sharing.");
|
|
377
|
+
}
|
|
294
378
|
console.error(`Simulated ${FRAMES[o.platform].label} phone at ${url} (Ctrl+C to stop)`);
|
|
295
379
|
if (o.open)
|
|
296
380
|
openBrowser(url);
|
package/dist/signing.js
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "n
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { b64, keyFromSeed } from "./index.js";
|
|
7
7
|
import { args, loadConfig, seed } from "./config.js";
|
|
8
|
+
import { rotate, RotateError } from "./rotation.js";
|
|
8
9
|
const pub = (s) => b64(keyFromSeed(s).publicKey);
|
|
9
10
|
const valid = (s) => { try {
|
|
10
11
|
return /^[A-Za-z0-9+/=_-]{43,44}$/.test(s) && !!pub(s);
|
|
@@ -15,6 +16,18 @@ catch {
|
|
|
15
16
|
export async function signingKey(argv) {
|
|
16
17
|
const [sub, ...rest] = argv, a = args(rest);
|
|
17
18
|
const cfg = loadConfig();
|
|
19
|
+
if (sub === "rotate") {
|
|
20
|
+
try {
|
|
21
|
+
await rotate(rest, cfg);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (!(e instanceof RotateError))
|
|
25
|
+
throw e;
|
|
26
|
+
console.error(e.message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
18
31
|
if (sub === "export") {
|
|
19
32
|
const s = seed();
|
|
20
33
|
if (pub(s) !== cfg.publicKey) {
|
package/dist/sim.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `shipvo sim`: build the app's web bundle with the simulated device and serve it in the simulator.
|
|
2
2
|
import { execSync, spawn } from "node:child_process";
|
|
3
|
+
import { spawnBin } from "./spawn-bin.js";
|
|
3
4
|
import { existsSync, mkdirSync, renameSync, rmSync, watch } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
@@ -91,12 +92,15 @@ export function watchAndRebuild(opts) {
|
|
|
91
92
|
*/
|
|
92
93
|
export function rebuildInto(opts = {}) {
|
|
93
94
|
const root = opts.root ?? process.cwd();
|
|
94
|
-
const command = opts.command ?? "npx expo export --platform web --clear --output-dir";
|
|
95
95
|
const next = join(root, ".shipvo", "sim-next"), live = join(root, "sim-dist"), prev = join(root, ".shipvo", "sim-prev");
|
|
96
96
|
return new Promise((resolve, reject) => {
|
|
97
97
|
mkdirSync(join(root, ".shipvo"), { recursive: true });
|
|
98
98
|
rmSync(next, { recursive: true, force: true });
|
|
99
|
-
|
|
99
|
+
// A custom command is a shell string. The default goes through spawnBin so Windows runs npx.cmd.
|
|
100
|
+
const call = opts.command
|
|
101
|
+
? { command: `${opts.command} "${next}"`, args: [], shell: true }
|
|
102
|
+
: spawnBin("npx", ["expo", "export", "--platform", "web", "--clear", "--output-dir", next]);
|
|
103
|
+
const p = spawn(call.command, call.args, { cwd: root, shell: call.shell, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
100
104
|
let tail = "";
|
|
101
105
|
// Metro and Expo print resolve errors to either stream, so keep both. `echo` also shows them.
|
|
102
106
|
for (const [stream, out] of [[p.stdout, process.stdout], [p.stderr, process.stderr]]) {
|
|
@@ -143,7 +147,7 @@ export async function sim(argv) {
|
|
|
143
147
|
}
|
|
144
148
|
if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
|
|
145
149
|
console.error(`Building ${cfg.name} for the simulator…`);
|
|
146
|
-
execSync("
|
|
150
|
+
execSync("npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
|
|
147
151
|
}
|
|
148
152
|
try {
|
|
149
153
|
ensurePlaywright({ cwd: process.cwd() });
|