@shipvo/cli 0.1.4 → 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 (93) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +29 -0
  4. package/dist/agents.js +183 -0
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +92 -0
  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 +11 -0
  20. package/dist/ci.js +100 -0
  21. package/dist/cli.js +51 -1
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.js +68 -13
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +88 -8
  26. package/dist/eas.d.ts +29 -0
  27. package/dist/eas.js +258 -0
  28. package/dist/ensure-playwright.d.ts +25 -0
  29. package/dist/ensure-playwright.js +69 -0
  30. package/dist/explore-issues.d.ts +12 -0
  31. package/dist/explore-issues.js +50 -0
  32. package/dist/explore.d.ts +160 -0
  33. package/dist/explore.js +496 -0
  34. package/dist/flows.d.ts +31 -0
  35. package/dist/flows.js +264 -0
  36. package/dist/help.d.ts +2 -2
  37. package/dist/help.js +37 -7
  38. package/dist/hermes.d.ts +10 -0
  39. package/dist/hermes.js +50 -0
  40. package/dist/init.d.ts +1 -0
  41. package/dist/init.js +71 -19
  42. package/dist/json-out.d.ts +53 -0
  43. package/dist/json-out.js +91 -0
  44. package/dist/keys-json.d.ts +1 -0
  45. package/dist/keys-json.js +71 -0
  46. package/dist/login.js +27 -11
  47. package/dist/lumi.d.ts +104 -0
  48. package/dist/lumi.js +364 -0
  49. package/dist/migrate.d.ts +27 -0
  50. package/dist/migrate.js +521 -0
  51. package/dist/policy.d.ts +2 -16
  52. package/dist/policy.js +2 -18
  53. package/dist/preview-recorder.d.ts +1 -0
  54. package/dist/preview-recorder.js +62 -0
  55. package/dist/preview.d.ts +4 -3
  56. package/dist/preview.js +114 -15
  57. package/dist/promote.d.ts +1 -1
  58. package/dist/promote.js +63 -14
  59. package/dist/publish.d.ts +14 -2
  60. package/dist/publish.js +131 -56
  61. package/dist/qr.d.ts +9 -0
  62. package/dist/qr.js +213 -0
  63. package/dist/report-md.d.ts +27 -0
  64. package/dist/report-md.js +58 -0
  65. package/dist/rotation.d.ts +57 -0
  66. package/dist/rotation.js +157 -0
  67. package/dist/run-bridge.d.ts +7 -0
  68. package/dist/run-bridge.js +728 -0
  69. package/dist/run-net.d.ts +39 -0
  70. package/dist/run-net.js +266 -0
  71. package/dist/run-protocol.d.ts +32 -0
  72. package/dist/run-protocol.js +22 -0
  73. package/dist/run-ws.d.ts +8 -0
  74. package/dist/run-ws.js +89 -0
  75. package/dist/run.d.ts +109 -0
  76. package/dist/run.js +385 -0
  77. package/dist/signing.js +13 -0
  78. package/dist/sim.d.ts +4 -0
  79. package/dist/sim.js +25 -7
  80. package/dist/smoke.d.ts +28 -0
  81. package/dist/smoke.js +117 -0
  82. package/dist/spawn-bin.d.ts +11 -0
  83. package/dist/spawn-bin.js +21 -0
  84. package/dist/status.d.ts +71 -1
  85. package/dist/status.js +162 -5
  86. package/dist/store.d.ts +36 -0
  87. package/dist/store.js +266 -3
  88. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  89. package/dist/templates/github-action/shipvo-check.yml +18 -0
  90. package/dist/test.d.ts +46 -0
  91. package/dist/test.js +253 -0
  92. package/package.json +7 -4
  93. package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/run.js ADDED
@@ -0,0 +1,385 @@
1
+ // `shipvo run android|ios`: build the web export with the simulated device, serve it on a local port and
2
+ // open a phone frame page in the default browser. No browser automation. `--native` runs `npx expo run:<platform>`.
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { networkInterfaces } from "node:os";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { createServer } from "node:http";
8
+ import { createRequire } from "node:module";
9
+ import { pathToFileURL } from "node:url";
10
+ import { join, extname, normalize, resolve, sep } from "node:path";
11
+ import { loadConfig } from "./config.js";
12
+ import { AGENT_PATH, hostAllowed, isAgentRole, isLoopback, originAllowed, tokenMatches } from "./run-protocol.js";
13
+ import { bridgeScript } from "./run-bridge.js";
14
+ import { NET_PATH, createNetProxy, loadProfiles, netClientSource } from "./run-net.js";
15
+ import { acceptWebSocket } from "./run-ws.js";
16
+ import { qrMatrix, qrToTerminal } from "./qr.js";
17
+ import { spawnBin } from "./spawn-bin.js";
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"));
29
+ /** Parses `run` arguments. Throws a plain message for anything it does not know. */
30
+ export function parseRunArgs(argv) {
31
+ let platform;
32
+ let open = true;
33
+ let port;
34
+ let native = false;
35
+ let lan = false;
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ if (a === "android" || a === "ios")
39
+ platform = a;
40
+ else if (a === "--no-open")
41
+ open = false;
42
+ else if (a === "--native")
43
+ native = true;
44
+ else if (a === "--lan")
45
+ lan = true;
46
+ else if (a === "--port") {
47
+ const v = argv[++i];
48
+ if (!v || !/^\d+$/.test(v) || Number(v) > 65535)
49
+ throw new Error(`--port needs a number from 0 to 65535, got "${v ?? ""}".`);
50
+ port = Number(v);
51
+ }
52
+ else
53
+ throw new Error(`Unknown argument "${a}". Usage: shipvo run android|ios [--no-open] [--port <n>] [--native] [--lan]`);
54
+ }
55
+ if (!platform)
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 };
60
+ }
61
+ /** The command `--native` runs. */
62
+ export const nativeCommand = (p) => ({ cmd: "npx", args: ["expo", `run:${p}`] });
63
+ export const FRAMES = {
64
+ android: { width: 412, height: 915, radius: 36, label: "Android", sheet: { title: "Allow access?", allow: "While using the app", deny: "Don't allow" } },
65
+ ios: { width: 393, height: 852, radius: 55, label: "iOS", sheet: { title: "Would Like to Access", allow: "Allow While Using App", deny: "Don't Allow" } },
66
+ };
67
+ /** Loads the frame renderer from @shipvo/sim: the app's install first, then the one next to this CLI. */
68
+ export async function loadFrameRenderer(bases = [process.cwd() + "/package.json", import.meta.url]) {
69
+ let file;
70
+ for (const base of bases) {
71
+ try {
72
+ file = createRequire(base).resolve("@shipvo/sim/frame.mjs");
73
+ break;
74
+ }
75
+ catch { }
76
+ }
77
+ if (!file)
78
+ throw new Error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
79
+ const mod = await import(pathToFileURL(file).href);
80
+ const render = (o) => mod.renderFrame(o);
81
+ if (typeof mod.statusClientScript === "function")
82
+ render.statusScript = mod.statusClientScript;
83
+ return render;
84
+ }
85
+ /** 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. */
86
+ export function runFramePage(platform, name, render, dark = false, token) {
87
+ const f = FRAMES[platform];
88
+ const title = name.replace(/[<>&"']/g, "");
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>`;
90
+ const frame = render({ skin: platform, dark, guide: true, screen: `<iframe id="app" src="/app/" title="${title}"></iframe>${sheet}` });
91
+ return `<!doctype html>
92
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
93
+ <title>${title} on ${f.label} (simulated)</title>
94
+ <style>
95
+ html,body{margin:0;height:100%;overflow:hidden;background:${dark ? "#15171a" : "#e8eaed"};font:14px system-ui,sans-serif}
96
+ #fit{position:absolute;left:50%;top:50%;transform-origin:center;touch-action:manipulation}
97
+ ${frame.css}
98
+ .sheet{position:absolute;inset:0;background:#0007;display:none;align-items:${platform === "ios" ? "center" : "flex-end"};justify-content:center;z-index:5}
99
+ .sheet.open{display:flex}
100
+ .sheet .box{background:#fff;color:#111;width:${platform === "ios" ? "270px" : "100%"};box-sizing:border-box;padding:20px;border-radius:${platform === "ios" ? "14px" : "28px 28px 0 0"};text-align:center}
101
+ .sheet button{display:block;width:100%;margin-top:8px;padding:10px;border:0;border-radius:${platform === "ios" ? "8px" : "20px"};background:#eee;font:inherit;cursor:pointer}
102
+ .sheetbtn{position:fixed;right:12px;top:10px;padding:3px 10px;border-radius:12px;border:0;background:#111;color:#fff;font-size:12px;cursor:pointer}
103
+ .tag{position:fixed;left:12px;top:10px;padding:3px 10px;border-radius:12px;background:#111;color:#fff;font-size:12px}
104
+ </style></head><body>
105
+ <div class="tag" role="status">Simulated ${f.label} (${f.width}x${f.height}). The app's web build on the simulated device.</div>
106
+ <button class="sheetbtn" id="sheetbtn" type="button">Show ${f.label} system sheet</button>
107
+ <div id="fit"><div id="phone" data-platform="${platform}">${frame.html}</div></div>
108
+ <script>
109
+ var sh=document.getElementById("sheet");
110
+ function sheet(on){sh.classList.toggle("open",on)}
111
+ document.getElementById("sheetbtn").onclick=function(){sheet(!sh.classList.contains("open"))};
112
+ sh.onclick=function(e){if(e.target===sh||e.target.getAttribute("data-act"))sheet(false)};
113
+ addEventListener("message",function(e){var d=e.data;if(d&&d.type==="shipvo:system-sheet")sheet(d.open!==false)});
114
+ function fit(){var s=Math.min(1,(innerWidth-24)/${frame.outerWidth},(innerHeight-60)/${frame.outerHeight});var e=document.getElementById("fit");e.style.transform="translate(-50%,-50%) scale("+s+")";e.style.width="${frame.outerWidth}px";e.style.height="${frame.outerHeight}px"}
115
+ addEventListener("resize",fit);fit();
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>`;
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
+ }
135
+ const TYPES = {
136
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
137
+ ".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon",
138
+ ".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".wasm": "application/wasm",
139
+ };
140
+ /** Adds `.shipvo/` to `<dir>/.gitignore` when it is not already ignored. */
141
+ export function ensureShipvoIgnored(dir) {
142
+ const path = join(dir, ".gitignore");
143
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
144
+ if (/^\/?\.shipvo\/?\s*$/m.test(text))
145
+ return;
146
+ appendFileSync(path, (text && !text.endsWith("\n") ? "\n" : "") + ".shipvo/\n");
147
+ }
148
+ /**
149
+ * Serves the frame page at `/` and the web export at `/app/` (and its absolute asset paths at `/`).
150
+ * Port 0 picks a free port. Resolves once listening.
151
+ * The agent channel is a WebSocket at `/__shipvo/agent?role=screen|driver&token=...`, loopback only. It relays JSON
152
+ * messages between the `screen` and the `driver`. With `projectDir`, `.shipvo/run.json` describes the session while it runs.
153
+ */
154
+ export async function startRunServer(opts) {
155
+ const root = resolve(opts.dir);
156
+ const token = randomBytes(24).toString("hex");
157
+ // The page gets its own credential: random, in memory only, never written to run.json, and valid for `screen` only.
158
+ const pageToken = randomBytes(24).toString("hex");
159
+ const render = opts.render ?? await loadFrameRenderer();
160
+ const pages = { light: runFramePage(opts.platform, opts.name, render, false, pageToken), dark: runFramePage(opts.platform, opts.name, render, true, pageToken) };
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;
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>`;
168
+ const server = createServer((req, res) => {
169
+ if (!hostAllowed(req.headers.host, boundPort) && !(opts.lan && req.headers.host === `${opts.lan.host}:${boundPort}`)) {
170
+ res.writeHead(403);
171
+ return void res.end("Forbidden");
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
+ }
183
+ let path;
184
+ try {
185
+ path = decodeURIComponent((req.url ?? "/").split("?")[0]);
186
+ }
187
+ catch {
188
+ res.writeHead(400);
189
+ return void res.end("Bad request");
190
+ }
191
+ if (path === "/app") {
192
+ res.writeHead(302, { location: "/app/" });
193
+ return void res.end();
194
+ }
195
+ if (path === "/" || path === "/index.html") {
196
+ const dark = new URL(req.url ?? "/", "http://x").searchParams.get("dark") === "1";
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);
200
+ }
201
+ if (path === NET_PATH)
202
+ return void net.handle(req, res);
203
+ if (path === "/__shipvo/bridge.js") {
204
+ res.writeHead(200, { "content-type": TYPES[".js"], "cache-control": "no-store" });
205
+ return void res.end(bridge);
206
+ }
207
+ let rel = path.startsWith("/app/") ? path.slice(4) : path;
208
+ if (rel.endsWith("/"))
209
+ rel += "index.html";
210
+ const file = normalize(join(root, rel));
211
+ if (file !== root && !file.startsWith(root + sep)) {
212
+ res.writeHead(403);
213
+ return void res.end("Forbidden");
214
+ }
215
+ let target = file;
216
+ if (!existsSync(target) || !statSync(target).isFile()) {
217
+ // A client-side route inside the frame (for example a reload on /app/settings): serve the app shell.
218
+ const spa = path.startsWith("/app/") && !extname(path) ? join(root, "index.html") : "";
219
+ if (!spa || !existsSync(spa)) {
220
+ res.writeHead(404);
221
+ return void res.end("Not found");
222
+ }
223
+ target = spa;
224
+ }
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
+ }
233
+ res.end(readFileSync(target));
234
+ });
235
+ const peers = {};
236
+ server.on("upgrade", (req, socket) => {
237
+ const refuse = (line) => socket.end(`HTTP/1.1 ${line}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
238
+ const url = new URL(req.url ?? "/", "http://localhost");
239
+ if (url.pathname !== AGENT_PATH)
240
+ return refuse("404 Not Found");
241
+ if (!isLoopback(req.socket.remoteAddress))
242
+ return refuse("403 Forbidden");
243
+ if (!hostAllowed(req.headers.host, boundPort) || !originAllowed(req.headers.origin, boundPort))
244
+ return refuse("403 Forbidden");
245
+ const given = url.searchParams.get("token");
246
+ const role = url.searchParams.get("role");
247
+ const ok = tokenMatches(given, token) || (role === "screen" && tokenMatches(given, pageToken));
248
+ if (!ok)
249
+ return refuse("401 Unauthorized");
250
+ if (!isAgentRole(role))
251
+ return refuse("400 Bad Request");
252
+ const other = role === "screen" ? "driver" : "screen";
253
+ const peer = acceptWebSocket(req, socket, (text) => {
254
+ try {
255
+ JSON.parse(text);
256
+ }
257
+ catch {
258
+ return;
259
+ }
260
+ peers[other]?.send(text);
261
+ }, () => { if (peers[role] === peer)
262
+ delete peers[role]; });
263
+ if (!peer)
264
+ return;
265
+ const old = peers[role]; // a reloaded tab or a restarted agent replaces the old connection
266
+ peers[role] = peer;
267
+ old?.close();
268
+ });
269
+ const infoPath = opts.projectDir ? join(resolve(opts.projectDir), ".shipvo", "run.json") : undefined;
270
+ const cleanup = () => { if (infoPath)
271
+ rmSync(infoPath, { force: true }); };
272
+ return new Promise((ok, fail) => {
273
+ server.once("error", fail);
274
+ server.listen(opts.port ?? 0, opts.lan ? "0.0.0.0" : "127.0.0.1", () => {
275
+ const port = server.address().port;
276
+ boundPort = port;
277
+ if (infoPath) {
278
+ const info = { port, platform: opts.platform, app: opts.appId ?? opts.name, token, pid: process.pid };
279
+ mkdirSync(join(infoPath, ".."), { recursive: true });
280
+ writeFileSync(infoPath, JSON.stringify(info, null, 2) + "\n", { mode: 0o600 });
281
+ }
282
+ ok({ server, port, token, lanToken, netLog: net.log, close: () => new Promise((r) => { cleanup(); for (const p of Object.values(peers))
283
+ p?.close(); server.close(() => r()); server.closeAllConnections?.(); }) });
284
+ });
285
+ });
286
+ }
287
+ /** Names the module or file a web build failed on, from the build's error output. */
288
+ export function describeWebBuildFailure(output) {
289
+ const m = /Unable to resolve module ['"]?([^'"\s]+)['"]?(?: from ['"]?([^'":\s]+))?/.exec(output)
290
+ ?? /Module not found: (?:Error: )?Can't resolve '([^']+)'(?: in '([^']+)')?/.exec(output)
291
+ ?? /Cannot find module ['"]([^'"]+)['"]/.exec(output);
292
+ if (m)
293
+ return `The app cannot build for web: ${m[1]} fails to resolve${m[2] ? ` (from ${m[2]})` : ""}. Fix or stub that module for web, or use \`shipvo run <platform> --native\`.`;
294
+ const f = /(?:SyntaxError|Error)[^\n]*\n?[^\n]*?(\S+\.(?:tsx?|jsx?|json))/.exec(output) ?? /(\S+\.(?:tsx?|jsx?|json))/.exec(output);
295
+ const last = output.trim().split("\n").slice(-1)[0] ?? "";
296
+ return `The app cannot build for web${f ? ` (in ${f[1]})` : ""}: ${last || "see the build output above"}. Use \`shipvo run <platform> --native\` to run it natively.`;
297
+ }
298
+ /** Builds the web export with `rebuildInto` from sim.ts (the same build as `shipvo sim`) into `<root>/sim-dist`. */
299
+ export async function buildWeb(root, command) {
300
+ try {
301
+ await rebuildInto({ root, command, echo: true });
302
+ }
303
+ catch (e) {
304
+ throw new Error(describeWebBuildFailure(e.output ?? e.message));
305
+ }
306
+ }
307
+ export function openBrowser(url) {
308
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
309
+ const p = spawn(cmd, args, { stdio: "ignore", detached: true });
310
+ p.on("error", () => console.error(`Could not open a browser. Open ${url} yourself.`));
311
+ p.unref();
312
+ }
313
+ /** Returns the exit code. */
314
+ export async function run(argv) {
315
+ if (argv.includes("--help") || argv.includes("-h")) {
316
+ console.log(RUN_USAGE);
317
+ return 0;
318
+ }
319
+ let o;
320
+ try {
321
+ o = parseRunArgs(argv);
322
+ }
323
+ catch (e) {
324
+ console.error(e.message);
325
+ return 2;
326
+ }
327
+ if (o.native) {
328
+ const { cmd, args } = nativeCommand(o.platform);
329
+ console.error(`Running natively: ${cmd} ${args.join(" ")} (not the simulated device)`);
330
+ const call = spawnBin(cmd, args);
331
+ return spawnSync(call.command, call.args, { stdio: "inherit", shell: call.shell }).status ?? 1;
332
+ }
333
+ let name, appId;
334
+ try {
335
+ const c = loadConfig();
336
+ name = c.name;
337
+ appId = c.appId;
338
+ }
339
+ catch (e) {
340
+ console.error(e.message);
341
+ return 1;
342
+ }
343
+ if (o.port !== undefined && o.port !== 0 && !(await portFree(o.port))) {
344
+ console.error(`Port ${o.port} is in use. Pass another --port <n>.`);
345
+ return 1;
346
+ }
347
+ const out = join(process.cwd(), "sim-dist");
348
+ if (isFirstBuild(process.cwd()))
349
+ console.error("First build, this can take a minute.");
350
+ console.error(`Building ${name} for web with the simulated device…`);
351
+ try {
352
+ await buildWeb(process.cwd());
353
+ }
354
+ catch (e) {
355
+ console.error(e.message);
356
+ return 1;
357
+ }
358
+ try {
359
+ ensureShipvoIgnored(process.cwd());
360
+ }
361
+ catch { /* read-only project: the session still works */ }
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
+ }
378
+ console.error(`Simulated ${FRAMES[o.platform].label} phone at ${url} (Ctrl+C to stop)`);
379
+ if (o.open)
380
+ openBrowser(url);
381
+ await new Promise((done) => { for (const sig of ["SIGINT", "SIGTERM"])
382
+ process.once(sig, done); });
383
+ await s.close();
384
+ return 0;
385
+ }
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.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** True if nothing is listening on the port (checks by binding, then closing). */
2
2
  export declare function portFree(port: number): Promise<boolean>;
3
+ /** Resolves true once something accepts connections on the port, false if `stop` says to give up. */
4
+ export declare function waitListening(port: number, stop: () => boolean): Promise<boolean>;
3
5
  /** True if a path (relative to the project) is inside a folder that never triggers a rebuild. */
4
6
  export declare const ignoredPath: (rel: string) => boolean;
5
7
  /** Turns a build error into a short plain message. Unknown errors keep their first line only. */
@@ -20,9 +22,11 @@ export declare function watchAndRebuild(opts: {
20
22
  * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
21
23
  * The old build is moved aside first and moved back if the swap fails.
22
24
  * `command` is the shell command; it gets the output dir as its last argument.
25
+ * A failed build rejects with an Error whose `output` property holds the last 4000 characters of stdout and stderr.
23
26
  */
24
27
  export declare function rebuildInto(opts?: {
25
28
  root?: string;
26
29
  command?: string;
30
+ echo?: boolean;
27
31
  }): Promise<void>;
28
32
  export declare function sim(argv: string[]): Promise<void>;
package/dist/sim.js CHANGED
@@ -1,9 +1,11 @@
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";
6
7
  import { createServer, connect } from "node:net";
8
+ import { ensurePlaywright } from "./ensure-playwright.js";
7
9
  import { args as parse, loadConfig } from "./config.js";
8
10
  /** True if nothing is listening on the port (checks by binding, then closing). */
9
11
  export function portFree(port) {
@@ -14,7 +16,7 @@ export function portFree(port) {
14
16
  });
15
17
  }
16
18
  /** Resolves true once something accepts connections on the port, false if `stop` says to give up. */
17
- function waitListening(port, stop) {
19
+ export function waitListening(port, stop) {
18
20
  return new Promise((resolve) => {
19
21
  const tryOnce = () => {
20
22
  if (stop())
@@ -86,22 +88,31 @@ export function watchAndRebuild(opts) {
86
88
  * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
87
89
  * The old build is moved aside first and moved back if the swap fails.
88
90
  * `command` is the shell command; it gets the output dir as its last argument.
91
+ * A failed build rejects with an Error whose `output` property holds the last 4000 characters of stdout and stderr.
89
92
  */
90
93
  export function rebuildInto(opts = {}) {
91
94
  const root = opts.root ?? process.cwd();
92
- const command = opts.command ?? "npx expo export --platform web --clear --output-dir";
93
95
  const next = join(root, ".shipvo", "sim-next"), live = join(root, "sim-dist"), prev = join(root, ".shipvo", "sim-prev");
94
96
  return new Promise((resolve, reject) => {
95
97
  mkdirSync(join(root, ".shipvo"), { recursive: true });
96
98
  rmSync(next, { recursive: true, force: true });
97
- const p = spawn(`${command} "${next}"`, { cwd: root, shell: true, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "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"] });
98
104
  let tail = "";
99
- p.stderr.on("data", (d) => { tail = (tail + d).slice(-4000); });
105
+ // Metro and Expo print resolve errors to either stream, so keep both. `echo` also shows them.
106
+ for (const [stream, out] of [[p.stdout, process.stdout], [p.stderr, process.stderr]]) {
107
+ stream.on("data", (d) => { if (opts.echo)
108
+ out.write(d); tail = (tail + d).slice(-4000); });
109
+ }
100
110
  p.on("error", (e) => { rmSync(next, { recursive: true, force: true }); reject(e); });
101
111
  p.on("close", (code) => {
102
112
  if (code !== 0) {
103
113
  rmSync(next, { recursive: true, force: true });
104
- return reject(new Error(tail.trim().split("\n").slice(-5).join("\n") || `build exited with code ${code}`));
114
+ const err = new Error(tail.trim().split("\n").slice(-5).join("\n") || `build exited with code ${code}`);
115
+ return reject(Object.assign(err, { output: tail }));
105
116
  }
106
117
  let moved = false;
107
118
  try {
@@ -136,14 +147,21 @@ export async function sim(argv) {
136
147
  }
137
148
  if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
138
149
  console.error(`Building ${cfg.name} for the simulator…`);
139
- 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" } });
151
+ }
152
+ try {
153
+ ensurePlaywright({ cwd: process.cwd() });
154
+ }
155
+ catch (e) {
156
+ console.error(e instanceof Error ? e.message : String(e));
157
+ process.exit(2);
140
158
  }
141
159
  let server;
142
160
  try {
143
161
  server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
144
162
  }
145
163
  catch {
146
- console.error("Install @shipvo/sim (and playwright) in this app: npm i -D @shipvo/sim playwright && npx playwright install chromium");
164
+ console.error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
147
165
  process.exit(2);
148
166
  }
149
167
  const p = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId, ...(a.dir ? ["--dir", a.dir] : [])], { stdio: "inherit", env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
@@ -0,0 +1,28 @@
1
+ import { type Driver, type Skin } from "./explore.js";
2
+ import { type Envelope } from "./json-out.js";
3
+ export type SmokeData = {
4
+ platform?: Skin;
5
+ time_to_first_screen_ms: number | null;
6
+ elements: number;
7
+ errors: string[];
8
+ error_count: number;
9
+ };
10
+ /** What smoke needs from the environment. Tests replace it. */
11
+ export type SmokeDeps = {
12
+ start: () => Promise<{
13
+ base: string;
14
+ stop: () => void;
15
+ }>;
16
+ driver: (base: string, app: string, session: string, platform: Skin) => Driver;
17
+ /** Current accessibility tree. Defaults to the simulator's `screen` action. */
18
+ screen: (base: string, session: string) => Promise<string>;
19
+ };
20
+ /** Elements in an accessibility tree: lines that carry a ref. */
21
+ export declare const countElements: (tree: string | null | undefined) => number;
22
+ /** Runs the check and returns the envelope and exit code. Never prints. */
23
+ export declare function smokeCheck(argv: string[], deps?: Partial<SmokeDeps>): Promise<{
24
+ doc: Envelope<string, unknown>;
25
+ exit: number;
26
+ }>;
27
+ /** Returns the exit code: 0 loads, 1 failed, 2 setup error. */
28
+ export declare function smoke(argv: string[]): Promise<number>;