@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.
Files changed (77) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +15 -0
  4. package/dist/agents.js +68 -9
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +28 -11
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +8 -0
  20. package/dist/ci.js +64 -6
  21. package/dist/cli.js +23 -1
  22. package/dist/config.d.ts +13 -0
  23. package/dist/config.js +43 -3
  24. package/dist/doctor.js +63 -7
  25. package/dist/ensure-playwright.d.ts +4 -0
  26. package/dist/ensure-playwright.js +7 -2
  27. package/dist/explore-issues.d.ts +12 -0
  28. package/dist/explore-issues.js +50 -0
  29. package/dist/explore.d.ts +83 -7
  30. package/dist/explore.js +326 -77
  31. package/dist/flows.d.ts +31 -0
  32. package/dist/flows.js +264 -0
  33. package/dist/help.d.ts +2 -2
  34. package/dist/help.js +19 -10
  35. package/dist/hermes.d.ts +10 -0
  36. package/dist/hermes.js +50 -0
  37. package/dist/init.d.ts +1 -0
  38. package/dist/init.js +41 -6
  39. package/dist/json-out.d.ts +53 -0
  40. package/dist/json-out.js +91 -0
  41. package/dist/keys-json.d.ts +1 -0
  42. package/dist/keys-json.js +71 -0
  43. package/dist/login.js +27 -11
  44. package/dist/lumi.d.ts +104 -0
  45. package/dist/lumi.js +364 -0
  46. package/dist/migrate.d.ts +27 -0
  47. package/dist/migrate.js +521 -0
  48. package/dist/preview.d.ts +1 -0
  49. package/dist/preview.js +4 -2
  50. package/dist/promote.d.ts +1 -1
  51. package/dist/promote.js +38 -16
  52. package/dist/publish.d.ts +14 -2
  53. package/dist/publish.js +126 -62
  54. package/dist/qr.d.ts +9 -0
  55. package/dist/qr.js +213 -0
  56. package/dist/report-md.d.ts +1 -0
  57. package/dist/report-md.js +1 -1
  58. package/dist/rotation.d.ts +57 -0
  59. package/dist/rotation.js +157 -0
  60. package/dist/run-bridge.js +356 -2
  61. package/dist/run-net.d.ts +39 -0
  62. package/dist/run-net.js +266 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +101 -17
  65. package/dist/signing.js +13 -0
  66. package/dist/sim.js +7 -3
  67. package/dist/smoke.d.ts +28 -0
  68. package/dist/smoke.js +117 -0
  69. package/dist/spawn-bin.d.ts +11 -0
  70. package/dist/spawn-bin.js +21 -0
  71. package/dist/status.d.ts +26 -4
  72. package/dist/status.js +69 -5
  73. package/dist/templates/github-action/shipvo-check.yml +18 -0
  74. package/dist/test.d.ts +1 -1
  75. package/dist/test.js +81 -18
  76. package/package.json +4 -3
  77. package/skills/shipvo-cli/SKILL.md +31 -0
@@ -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
- return { platform, open, port, native };
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
- res.writeHead(200, { "content-type": TYPES[".html"] });
147
- return void res.end(dark ? pages.dark : pages.light);
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
- res.writeHead(200, { "content-type": TYPES[extname(target).toLowerCase()] ?? "application/octet-stream" });
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
- return spawnSync(cmd, args, { stdio: "inherit" }).status ?? 1;
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
- name = loadConfig().name;
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
- const s = await startRunServer({ dir: out, platform: o.platform, name, port: o.port, projectDir: process.cwd() });
293
- const url = `http://127.0.0.1:${s.port}/`;
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
- const p = spawn(`${command} "${next}"`, { cwd: root, shell: true, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "pipe", "pipe"] });
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("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
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() });