@shipvo/cli 0.1.3 → 0.1.5

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 (58) hide show
  1. package/dist/adopt-bare.d.ts +39 -0
  2. package/dist/adopt-bare.js +236 -0
  3. package/dist/adopt.d.ts +26 -0
  4. package/dist/adopt.js +257 -0
  5. package/dist/agents.d.ts +14 -0
  6. package/dist/agents.js +124 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +75 -0
  9. package/dist/autohalt.d.ts +5 -0
  10. package/dist/autohalt.js +84 -0
  11. package/dist/ci.d.ts +3 -0
  12. package/dist/ci.js +42 -0
  13. package/dist/cli.js +142 -88
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.js +38 -10
  16. package/dist/doctor.d.ts +14 -0
  17. package/dist/doctor.js +132 -7
  18. package/dist/eas.d.ts +29 -0
  19. package/dist/eas.js +258 -0
  20. package/dist/ensure-playwright.d.ts +21 -0
  21. package/dist/ensure-playwright.js +64 -0
  22. package/dist/explore.d.ts +84 -0
  23. package/dist/explore.js +247 -0
  24. package/dist/help.d.ts +4 -2
  25. package/dist/help.js +39 -3
  26. package/dist/init.js +501 -17
  27. package/dist/native.d.ts +3 -0
  28. package/dist/native.js +39 -0
  29. package/dist/policy.d.ts +4 -0
  30. package/dist/policy.js +17 -0
  31. package/dist/preview-recorder.d.ts +1 -0
  32. package/dist/preview-recorder.js +62 -0
  33. package/dist/preview.d.ts +35 -0
  34. package/dist/preview.js +346 -0
  35. package/dist/promote.d.ts +9 -0
  36. package/dist/promote.js +153 -0
  37. package/dist/publish.d.ts +1 -1
  38. package/dist/publish.js +73 -29
  39. package/dist/report-md.d.ts +26 -0
  40. package/dist/report-md.js +58 -0
  41. package/dist/run-bridge.d.ts +7 -0
  42. package/dist/run-bridge.js +374 -0
  43. package/dist/run-protocol.d.ts +32 -0
  44. package/dist/run-protocol.js +22 -0
  45. package/dist/run-ws.d.ts +8 -0
  46. package/dist/run-ws.js +89 -0
  47. package/dist/run.d.ts +86 -0
  48. package/dist/run.js +301 -0
  49. package/dist/sim.d.ts +31 -0
  50. package/dist/sim.js +124 -5
  51. package/dist/status.d.ts +64 -0
  52. package/dist/status.js +147 -0
  53. package/dist/store.d.ts +38 -0
  54. package/dist/store.js +297 -0
  55. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  56. package/dist/test.d.ts +46 -0
  57. package/dist/test.js +190 -0
  58. package/package.json +10 -5
package/dist/run.js ADDED
@@ -0,0 +1,301 @@
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 { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import { createRequire } from "node:module";
8
+ import { pathToFileURL } from "node:url";
9
+ import { join, extname, normalize, resolve, sep } from "node:path";
10
+ import { loadConfig } from "./config.js";
11
+ import { AGENT_PATH, hostAllowed, isAgentRole, isLoopback, originAllowed, tokenMatches } from "./run-protocol.js";
12
+ import { bridgeScript } from "./run-bridge.js";
13
+ import { acceptWebSocket } from "./run-ws.js";
14
+ import { portFree, rebuildInto } from "./sim.js";
15
+ /** Parses `run` arguments. Throws a plain message for anything it does not know. */
16
+ export function parseRunArgs(argv) {
17
+ let platform;
18
+ let open = true;
19
+ let port;
20
+ let native = false;
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (a === "android" || a === "ios")
24
+ platform = a;
25
+ else if (a === "--no-open")
26
+ open = false;
27
+ else if (a === "--native")
28
+ native = true;
29
+ else if (a === "--port") {
30
+ const v = argv[++i];
31
+ if (!v || !/^\d+$/.test(v) || Number(v) > 65535)
32
+ throw new Error(`--port needs a number from 0 to 65535, got "${v ?? ""}".`);
33
+ port = Number(v);
34
+ }
35
+ else
36
+ throw new Error(`Unknown argument "${a}". Usage: shipvo run android|ios [--no-open] [--port <n>] [--native]`);
37
+ }
38
+ if (!platform)
39
+ throw new Error("Say which phone: shipvo run android or shipvo run ios.");
40
+ return { platform, open, port, native };
41
+ }
42
+ /** The command `--native` runs. */
43
+ export const nativeCommand = (p) => ({ cmd: "npx", args: ["expo", `run:${p}`] });
44
+ export const FRAMES = {
45
+ android: { width: 412, height: 915, radius: 36, label: "Android", sheet: { title: "Allow access?", allow: "While using the app", deny: "Don't allow" } },
46
+ ios: { width: 393, height: 852, radius: 55, label: "iOS", sheet: { title: "Would Like to Access", allow: "Allow While Using App", deny: "Don't Allow" } },
47
+ };
48
+ /** Loads the frame renderer from @shipvo/sim: the app's install first, then the one next to this CLI. */
49
+ export async function loadFrameRenderer(bases = [process.cwd() + "/package.json", import.meta.url]) {
50
+ let file;
51
+ for (const base of bases) {
52
+ try {
53
+ file = createRequire(base).resolve("@shipvo/sim/frame.mjs");
54
+ break;
55
+ }
56
+ catch { }
57
+ }
58
+ if (!file)
59
+ throw new Error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
60
+ const mod = await import(pathToFileURL(file).href);
61
+ const render = (o) => mod.renderFrame(o);
62
+ if (typeof mod.statusClientScript === "function")
63
+ render.statusScript = mod.statusClientScript;
64
+ return render;
65
+ }
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. */
67
+ export function runFramePage(platform, name, render, dark = false, token) {
68
+ const f = FRAMES[platform];
69
+ const title = name.replace(/[<>&"']/g, "");
70
+ 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}` });
72
+ return `<!doctype html>
73
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
74
+ <title>${title} on ${f.label} (simulated)</title>
75
+ <style>
76
+ html,body{margin:0;height:100%;overflow:hidden;background:${dark ? "#15171a" : "#e8eaed"};font:14px system-ui,sans-serif}
77
+ #fit{position:absolute;left:50%;top:50%;transform-origin:center;touch-action:manipulation}
78
+ ${frame.css}
79
+ .sheet{position:absolute;inset:0;background:#0007;display:none;align-items:${platform === "ios" ? "center" : "flex-end"};justify-content:center;z-index:5}
80
+ .sheet.open{display:flex}
81
+ .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}
82
+ .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}
83
+ .sheetbtn{position:fixed;right:12px;top:10px;padding:3px 10px;border-radius:12px;border:0;background:#111;color:#fff;font-size:12px;cursor:pointer}
84
+ .tag{position:fixed;left:12px;top:10px;padding:3px 10px;border-radius:12px;background:#111;color:#fff;font-size:12px}
85
+ </style></head><body>
86
+ <div class="tag" role="status">Simulated ${f.label} (${f.width}x${f.height}). The app's web build on the simulated device.</div>
87
+ <button class="sheetbtn" id="sheetbtn" type="button">Show ${f.label} system sheet</button>
88
+ <div id="fit"><div id="phone" data-platform="${platform}">${frame.html}</div></div>
89
+ <script>
90
+ var sh=document.getElementById("sheet");
91
+ function sheet(on){sh.classList.toggle("open",on)}
92
+ document.getElementById("sheetbtn").onclick=function(){sheet(!sh.classList.contains("open"))};
93
+ sh.onclick=function(e){if(e.target===sh||e.target.getAttribute("data-act"))sheet(false)};
94
+ addEventListener("message",function(e){var d=e.data;if(d&&d.type==="shipvo:system-sheet")sheet(d.open!==false)});
95
+ 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"}
96
+ addEventListener("resize",fit);fit();
97
+ </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
+ }
99
+ const TYPES = {
100
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
101
+ ".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon",
102
+ ".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".wasm": "application/wasm",
103
+ };
104
+ /** Adds `.shipvo/` to `<dir>/.gitignore` when it is not already ignored. */
105
+ export function ensureShipvoIgnored(dir) {
106
+ const path = join(dir, ".gitignore");
107
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
108
+ if (/^\/?\.shipvo\/?\s*$/m.test(text))
109
+ return;
110
+ appendFileSync(path, (text && !text.endsWith("\n") ? "\n" : "") + ".shipvo/\n");
111
+ }
112
+ /**
113
+ * Serves the frame page at `/` and the web export at `/app/` (and its absolute asset paths at `/`).
114
+ * Port 0 picks a free port. Resolves once listening.
115
+ * The agent channel is a WebSocket at `/__shipvo/agent?role=screen|driver&token=...`, loopback only. It relays JSON
116
+ * messages between the `screen` and the `driver`. With `projectDir`, `.shipvo/run.json` describes the session while it runs.
117
+ */
118
+ export async function startRunServer(opts) {
119
+ const root = resolve(opts.dir);
120
+ const token = randomBytes(24).toString("hex");
121
+ // The page gets its own credential: random, in memory only, never written to run.json, and valid for `screen` only.
122
+ const pageToken = randomBytes(24).toString("hex");
123
+ const render = opts.render ?? await loadFrameRenderer();
124
+ const pages = { light: runFramePage(opts.platform, opts.name, render, false, pageToken), dark: runFramePage(opts.platform, opts.name, render, true, pageToken) };
125
+ const bridge = bridgeScript();
126
+ let boundPort = -1; // set once the server listens; Host and Origin must match it
127
+ const server = createServer((req, res) => {
128
+ if (!hostAllowed(req.headers.host, boundPort)) {
129
+ res.writeHead(403);
130
+ return void res.end("Forbidden");
131
+ }
132
+ let path;
133
+ try {
134
+ path = decodeURIComponent((req.url ?? "/").split("?")[0]);
135
+ }
136
+ catch {
137
+ res.writeHead(400);
138
+ return void res.end("Bad request");
139
+ }
140
+ if (path === "/app") {
141
+ res.writeHead(302, { location: "/app/" });
142
+ return void res.end();
143
+ }
144
+ if (path === "/" || path === "/index.html") {
145
+ 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);
148
+ }
149
+ if (path === "/__shipvo/bridge.js") {
150
+ res.writeHead(200, { "content-type": TYPES[".js"], "cache-control": "no-store" });
151
+ return void res.end(bridge);
152
+ }
153
+ let rel = path.startsWith("/app/") ? path.slice(4) : path;
154
+ if (rel.endsWith("/"))
155
+ rel += "index.html";
156
+ const file = normalize(join(root, rel));
157
+ if (file !== root && !file.startsWith(root + sep)) {
158
+ res.writeHead(403);
159
+ return void res.end("Forbidden");
160
+ }
161
+ let target = file;
162
+ if (!existsSync(target) || !statSync(target).isFile()) {
163
+ // A client-side route inside the frame (for example a reload on /app/settings): serve the app shell.
164
+ const spa = path.startsWith("/app/") && !extname(path) ? join(root, "index.html") : "";
165
+ if (!spa || !existsSync(spa)) {
166
+ res.writeHead(404);
167
+ return void res.end("Not found");
168
+ }
169
+ target = spa;
170
+ }
171
+ res.writeHead(200, { "content-type": TYPES[extname(target).toLowerCase()] ?? "application/octet-stream" });
172
+ res.end(readFileSync(target));
173
+ });
174
+ const peers = {};
175
+ server.on("upgrade", (req, socket) => {
176
+ const refuse = (line) => socket.end(`HTTP/1.1 ${line}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
177
+ const url = new URL(req.url ?? "/", "http://localhost");
178
+ if (url.pathname !== AGENT_PATH)
179
+ return refuse("404 Not Found");
180
+ if (!isLoopback(req.socket.remoteAddress))
181
+ return refuse("403 Forbidden");
182
+ if (!hostAllowed(req.headers.host, boundPort) || !originAllowed(req.headers.origin, boundPort))
183
+ return refuse("403 Forbidden");
184
+ const given = url.searchParams.get("token");
185
+ const role = url.searchParams.get("role");
186
+ const ok = tokenMatches(given, token) || (role === "screen" && tokenMatches(given, pageToken));
187
+ if (!ok)
188
+ return refuse("401 Unauthorized");
189
+ if (!isAgentRole(role))
190
+ return refuse("400 Bad Request");
191
+ const other = role === "screen" ? "driver" : "screen";
192
+ const peer = acceptWebSocket(req, socket, (text) => {
193
+ try {
194
+ JSON.parse(text);
195
+ }
196
+ catch {
197
+ return;
198
+ }
199
+ peers[other]?.send(text);
200
+ }, () => { if (peers[role] === peer)
201
+ delete peers[role]; });
202
+ if (!peer)
203
+ return;
204
+ const old = peers[role]; // a reloaded tab or a restarted agent replaces the old connection
205
+ peers[role] = peer;
206
+ old?.close();
207
+ });
208
+ const infoPath = opts.projectDir ? join(resolve(opts.projectDir), ".shipvo", "run.json") : undefined;
209
+ const cleanup = () => { if (infoPath)
210
+ rmSync(infoPath, { force: true }); };
211
+ return new Promise((ok, fail) => {
212
+ server.once("error", fail);
213
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
214
+ const port = server.address().port;
215
+ boundPort = port;
216
+ if (infoPath) {
217
+ const info = { port, platform: opts.platform, app: opts.name, token, pid: process.pid };
218
+ mkdirSync(join(infoPath, ".."), { recursive: true });
219
+ writeFileSync(infoPath, JSON.stringify(info, null, 2) + "\n", { mode: 0o600 });
220
+ }
221
+ ok({ server, port, token, close: () => new Promise((r) => { cleanup(); for (const p of Object.values(peers))
222
+ p?.close(); server.close(() => r()); server.closeAllConnections?.(); }) });
223
+ });
224
+ });
225
+ }
226
+ /** Names the module or file a web build failed on, from the build's error output. */
227
+ export function describeWebBuildFailure(output) {
228
+ const m = /Unable to resolve module ['"]?([^'"\s]+)['"]?(?: from ['"]?([^'":\s]+))?/.exec(output)
229
+ ?? /Module not found: (?:Error: )?Can't resolve '([^']+)'(?: in '([^']+)')?/.exec(output)
230
+ ?? /Cannot find module ['"]([^'"]+)['"]/.exec(output);
231
+ if (m)
232
+ 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\`.`;
233
+ const f = /(?:SyntaxError|Error)[^\n]*\n?[^\n]*?(\S+\.(?:tsx?|jsx?|json))/.exec(output) ?? /(\S+\.(?:tsx?|jsx?|json))/.exec(output);
234
+ const last = output.trim().split("\n").slice(-1)[0] ?? "";
235
+ 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.`;
236
+ }
237
+ /** Builds the web export with `rebuildInto` from sim.ts (the same build as `shipvo sim`) into `<root>/sim-dist`. */
238
+ export async function buildWeb(root, command) {
239
+ try {
240
+ await rebuildInto({ root, command, echo: true });
241
+ }
242
+ catch (e) {
243
+ throw new Error(describeWebBuildFailure(e.output ?? e.message));
244
+ }
245
+ }
246
+ function openBrowser(url) {
247
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
248
+ const p = spawn(cmd, args, { stdio: "ignore", detached: true });
249
+ p.on("error", () => console.error(`Could not open a browser. Open ${url} yourself.`));
250
+ p.unref();
251
+ }
252
+ /** Returns the exit code. */
253
+ export async function run(argv) {
254
+ let o;
255
+ try {
256
+ o = parseRunArgs(argv);
257
+ }
258
+ catch (e) {
259
+ console.error(e.message);
260
+ return 2;
261
+ }
262
+ if (o.native) {
263
+ const { cmd, args } = nativeCommand(o.platform);
264
+ console.error(`Running natively: ${cmd} ${args.join(" ")} (not the simulated device)`);
265
+ return spawnSync(cmd, args, { stdio: "inherit" }).status ?? 1;
266
+ }
267
+ let name;
268
+ try {
269
+ name = loadConfig().name;
270
+ }
271
+ catch (e) {
272
+ console.error(e.message);
273
+ return 1;
274
+ }
275
+ if (o.port !== undefined && o.port !== 0 && !(await portFree(o.port))) {
276
+ console.error(`Port ${o.port} is in use. Pass another --port <n>.`);
277
+ return 1;
278
+ }
279
+ const out = join(process.cwd(), "sim-dist");
280
+ console.error(`Building ${name} for web with the simulated device…`);
281
+ try {
282
+ await buildWeb(process.cwd());
283
+ }
284
+ catch (e) {
285
+ console.error(e.message);
286
+ return 1;
287
+ }
288
+ try {
289
+ ensureShipvoIgnored(process.cwd());
290
+ }
291
+ 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}/`;
294
+ console.error(`Simulated ${FRAMES[o.platform].label} phone at ${url} (Ctrl+C to stop)`);
295
+ if (o.open)
296
+ openBrowser(url);
297
+ await new Promise((done) => { for (const sig of ["SIGINT", "SIGTERM"])
298
+ process.once(sig, done); });
299
+ await s.close();
300
+ return 0;
301
+ }
package/dist/sim.d.ts CHANGED
@@ -1 +1,32 @@
1
+ /** True if nothing is listening on the port (checks by binding, then closing). */
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>;
5
+ /** True if a path (relative to the project) is inside a folder that never triggers a rebuild. */
6
+ export declare const ignoredPath: (rel: string) => boolean;
7
+ /** Turns a build error into a short plain message. Unknown errors keep their first line only. */
8
+ export declare function friendlyBuildError(message: string, verbose?: boolean): string;
9
+ /**
10
+ * Calls `build` once after changes settle for `debounceMs`. A change during a build queues one more build.
11
+ * `build` rejects with the build error; it is printed and the caller keeps serving the last good build.
12
+ * Returns a function that stops watching.
13
+ */
14
+ export declare function watchAndRebuild(opts: {
15
+ dir: string;
16
+ build: () => Promise<void>;
17
+ debounceMs?: number;
18
+ log?: (line: string) => void;
19
+ verbose?: boolean;
20
+ }): () => void;
21
+ /**
22
+ * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
23
+ * The old build is moved aside first and moved back if the swap fails.
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.
26
+ */
27
+ export declare function rebuildInto(opts?: {
28
+ root?: string;
29
+ command?: string;
30
+ echo?: boolean;
31
+ }): Promise<void>;
1
32
  export declare function sim(argv: string[]): Promise<void>;
package/dist/sim.js CHANGED
@@ -1,11 +1,13 @@
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 { existsSync } from "node:fs";
3
+ import { existsSync, mkdirSync, renameSync, rmSync, watch } from "node:fs";
4
+ import { join } from "node:path";
4
5
  import { createRequire } from "node:module";
5
6
  import { createServer, connect } from "node:net";
7
+ import { ensurePlaywright } from "./ensure-playwright.js";
6
8
  import { args as parse, loadConfig } from "./config.js";
7
9
  /** True if nothing is listening on the port (checks by binding, then closing). */
8
- function portFree(port) {
10
+ export function portFree(port) {
9
11
  return new Promise((resolve) => {
10
12
  const s = createServer();
11
13
  s.once("error", () => resolve(false));
@@ -13,7 +15,7 @@ function portFree(port) {
13
15
  });
14
16
  }
15
17
  /** Resolves true once something accepts connections on the port, false if `stop` says to give up. */
16
- function waitListening(port, stop) {
18
+ export function waitListening(port, stop) {
17
19
  return new Promise((resolve) => {
18
20
  const tryOnce = () => {
19
21
  if (stop())
@@ -25,6 +27,112 @@ function waitListening(port, stop) {
25
27
  tryOnce();
26
28
  });
27
29
  }
30
+ const IGNORED = new Set(["node_modules", "sim-dist", ".shipvo", ".git", ".expo", "dist", "dist-ota", "web-build", "ios", "android"]);
31
+ /** True if a path (relative to the project) is inside a folder that never triggers a rebuild. */
32
+ export const ignoredPath = (rel) => rel.split(/[\\/]/).some((seg) => IGNORED.has(seg));
33
+ /** Turns a build error into a short plain message. Unknown errors keep their first line only. */
34
+ export function friendlyBuildError(message, verbose = false) {
35
+ if (verbose)
36
+ return `build failed, still serving the last good build: ${message}`;
37
+ const m = /in file (\S+\.json) at (\d+):(\d+)/.exec(message);
38
+ if (m && /Unexpected token|SyntaxError/.test(message)) {
39
+ const comma = /expected punc [«"']?,/.test(message) || /Unexpected token string/.test(message);
40
+ const what = comma ? "check for a missing or extra comma" : "a character is missing, extra or in the wrong place";
41
+ return `Your file ${m[1]} has a mistake at line ${m[2]}, column ${m[3]}: ${what}. The page still shows your last good version.`;
42
+ }
43
+ return `build failed, still serving the last good build: ${message.split("\n")[0]} (run with --verbose for details)`;
44
+ }
45
+ /**
46
+ * Calls `build` once after changes settle for `debounceMs`. A change during a build queues one more build.
47
+ * `build` rejects with the build error; it is printed and the caller keeps serving the last good build.
48
+ * Returns a function that stops watching.
49
+ */
50
+ export function watchAndRebuild(opts) {
51
+ const { dir, build, debounceMs = 300, log = (l) => console.error(l), verbose = false } = opts;
52
+ let timer;
53
+ let running = false;
54
+ let again = false;
55
+ let closed = false;
56
+ const run = async () => {
57
+ if (running) {
58
+ again = true;
59
+ return;
60
+ }
61
+ running = true;
62
+ const t0 = Date.now();
63
+ try {
64
+ await build();
65
+ log(`rebuilt in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
66
+ }
67
+ catch (e) {
68
+ log(friendlyBuildError(e.message, verbose));
69
+ }
70
+ running = false;
71
+ if (again && !closed) {
72
+ again = false;
73
+ void run();
74
+ }
75
+ };
76
+ const w = watch(dir, { recursive: true }, (_ev, file) => {
77
+ if (closed || (file && ignoredPath(String(file))))
78
+ return;
79
+ clearTimeout(timer);
80
+ timer = setTimeout(() => void run(), debounceMs);
81
+ });
82
+ const stop = () => { closed = true; clearTimeout(timer); w.close(); };
83
+ w.on("error", (e) => { log(`file watcher failed, no longer watching: ${e.message}`); stop(); });
84
+ return stop;
85
+ }
86
+ /**
87
+ * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
88
+ * The old build is moved aside first and moved back if the swap fails.
89
+ * `command` is the shell command; it gets the output dir as its last argument.
90
+ * A failed build rejects with an Error whose `output` property holds the last 4000 characters of stdout and stderr.
91
+ */
92
+ export function rebuildInto(opts = {}) {
93
+ const root = opts.root ?? process.cwd();
94
+ const command = opts.command ?? "npx expo export --platform web --clear --output-dir";
95
+ const next = join(root, ".shipvo", "sim-next"), live = join(root, "sim-dist"), prev = join(root, ".shipvo", "sim-prev");
96
+ return new Promise((resolve, reject) => {
97
+ mkdirSync(join(root, ".shipvo"), { recursive: true });
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"] });
100
+ let tail = "";
101
+ // Metro and Expo print resolve errors to either stream, so keep both. `echo` also shows them.
102
+ for (const [stream, out] of [[p.stdout, process.stdout], [p.stderr, process.stderr]]) {
103
+ stream.on("data", (d) => { if (opts.echo)
104
+ out.write(d); tail = (tail + d).slice(-4000); });
105
+ }
106
+ p.on("error", (e) => { rmSync(next, { recursive: true, force: true }); reject(e); });
107
+ p.on("close", (code) => {
108
+ if (code !== 0) {
109
+ rmSync(next, { recursive: true, force: true });
110
+ const err = new Error(tail.trim().split("\n").slice(-5).join("\n") || `build exited with code ${code}`);
111
+ return reject(Object.assign(err, { output: tail }));
112
+ }
113
+ let moved = false;
114
+ try {
115
+ rmSync(prev, { recursive: true, force: true });
116
+ if (existsSync(live)) {
117
+ renameSync(live, prev);
118
+ moved = true;
119
+ }
120
+ renameSync(next, live);
121
+ }
122
+ catch (e) {
123
+ try {
124
+ if (moved && !existsSync(live))
125
+ renameSync(prev, live);
126
+ }
127
+ catch { /* keep the original error */ }
128
+ rmSync(next, { recursive: true, force: true });
129
+ return reject(e);
130
+ }
131
+ rmSync(prev, { recursive: true, force: true });
132
+ resolve();
133
+ });
134
+ });
135
+ }
28
136
  export async function sim(argv) {
29
137
  const a = parse(argv);
30
138
  const cfg = loadConfig();
@@ -37,18 +145,29 @@ export async function sim(argv) {
37
145
  console.error(`Building ${cfg.name} for the simulator…`);
38
146
  execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
39
147
  }
148
+ try {
149
+ ensurePlaywright({ cwd: process.cwd() });
150
+ }
151
+ catch (e) {
152
+ console.error(e instanceof Error ? e.message : String(e));
153
+ process.exit(2);
154
+ }
40
155
  let server;
41
156
  try {
42
157
  server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
43
158
  }
44
159
  catch {
45
- console.error("Install @shipvo/sim (and playwright) in this app: npm i -D @shipvo/sim playwright && npx playwright install chromium");
160
+ console.error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
46
161
  process.exit(2);
47
162
  }
48
163
  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 } });
49
164
  let exited = false;
50
165
  p.on("exit", (c) => { exited = true; process.exit(c ?? 0); });
51
166
  if (await waitListening(Number(port), () => exited)) {
52
- console.error(`Simulator on http://localhost:${port} (open_app("${cfg.appId}") from @shipvo/mcp; Ctrl-C to stop)`);
167
+ console.error(`Simulator on http://localhost:${port}/app/ (open_app("${cfg.appId}") from @shipvo/mcp; Ctrl-C to stop)`);
168
+ if (a.watch === "true") {
169
+ watchAndRebuild({ dir: process.cwd(), build: () => rebuildInto(), verbose: a.verbose === "true" });
170
+ console.error("Watching for changes (ignoring node_modules, sim-dist, .shipvo, .expo, dist, dist-ota, web-build, ios, android)");
171
+ }
53
172
  }
54
173
  }
@@ -0,0 +1,64 @@
1
+ type Row = {
2
+ release: number;
3
+ events: Record<string, number>;
4
+ apply_ms_median: number | null;
5
+ cold_start_ms_median: number | null;
6
+ };
7
+ type AutoHalt = {
8
+ threshold: number;
9
+ min_reports: number;
10
+ } | null;
11
+ type Halted = {
12
+ release: number;
13
+ reports: number;
14
+ failed: number;
15
+ threshold: number;
16
+ };
17
+ /** Plain-text table: one row per release, one column per event name, then the two medians. */
18
+ export declare function formatStatus(releases: Row[]): string;
19
+ /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
20
+ export declare function describeAutoHalt(c: AutoHalt | undefined): string;
21
+ export type Next = {
22
+ command: string;
23
+ why: string;
24
+ };
25
+ export type StatusJson = {
26
+ ok: boolean;
27
+ code: "ok" | "no_project" | "no_signing_key" | "release_halted" | "no_release" | "error";
28
+ summary: string;
29
+ data: {
30
+ project: {
31
+ kind: string;
32
+ app_id: string | null;
33
+ };
34
+ sim: {
35
+ running: boolean;
36
+ port: number;
37
+ };
38
+ run_server: {
39
+ running: boolean;
40
+ port: number | null;
41
+ };
42
+ signing_key: {
43
+ present: boolean;
44
+ };
45
+ pending_release: {
46
+ newest: number | null;
47
+ halted: Halted | null;
48
+ auto_halt: AutoHalt | null;
49
+ } | null;
50
+ store_step: {
51
+ id: string;
52
+ title: string;
53
+ state: string;
54
+ } | null;
55
+ };
56
+ next: Next[];
57
+ };
58
+ /** `shipvo status --json`: exit 0 ok, 1 action needed, 2 error. */
59
+ export declare function statusJson(appArg: string | undefined, fetchFn: typeof fetch, dir?: string): Promise<{
60
+ doc: StatusJson;
61
+ exit: number;
62
+ }>;
63
+ export declare function status(argv: string[], fetchFn?: typeof fetch): Promise<number>;
64
+ export {};