@shipvo/cli 0.1.4 → 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 (50) hide show
  1. package/dist/agents.d.ts +14 -0
  2. package/dist/agents.js +124 -0
  3. package/dist/audit.d.ts +13 -0
  4. package/dist/audit.js +75 -0
  5. package/dist/ci.d.ts +3 -0
  6. package/dist/ci.js +42 -0
  7. package/dist/cli.js +28 -0
  8. package/dist/config.d.ts +2 -0
  9. package/dist/config.js +25 -10
  10. package/dist/doctor.d.ts +2 -0
  11. package/dist/doctor.js +25 -1
  12. package/dist/eas.d.ts +29 -0
  13. package/dist/eas.js +258 -0
  14. package/dist/ensure-playwright.d.ts +21 -0
  15. package/dist/ensure-playwright.js +64 -0
  16. package/dist/explore.d.ts +84 -0
  17. package/dist/explore.js +247 -0
  18. package/dist/help.d.ts +2 -2
  19. package/dist/help.js +24 -3
  20. package/dist/init.js +31 -14
  21. package/dist/policy.d.ts +2 -16
  22. package/dist/policy.js +2 -18
  23. package/dist/preview-recorder.d.ts +1 -0
  24. package/dist/preview-recorder.js +62 -0
  25. package/dist/preview.d.ts +3 -3
  26. package/dist/preview.js +110 -13
  27. package/dist/promote.d.ts +1 -1
  28. package/dist/promote.js +29 -2
  29. package/dist/publish.d.ts +1 -1
  30. package/dist/publish.js +12 -1
  31. package/dist/report-md.d.ts +26 -0
  32. package/dist/report-md.js +58 -0
  33. package/dist/run-bridge.d.ts +7 -0
  34. package/dist/run-bridge.js +374 -0
  35. package/dist/run-protocol.d.ts +32 -0
  36. package/dist/run-protocol.js +22 -0
  37. package/dist/run-ws.d.ts +8 -0
  38. package/dist/run-ws.js +89 -0
  39. package/dist/run.d.ts +86 -0
  40. package/dist/run.js +301 -0
  41. package/dist/sim.d.ts +4 -0
  42. package/dist/sim.js +19 -5
  43. package/dist/status.d.ts +48 -0
  44. package/dist/status.js +97 -4
  45. package/dist/store.d.ts +36 -0
  46. package/dist/store.js +266 -3
  47. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  48. package/dist/test.d.ts +46 -0
  49. package/dist/test.js +190 -0
  50. package/package.json +6 -4
package/dist/run.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { type Server } from "node:http";
2
+ export type Platform = "android" | "ios";
3
+ export type RunOptions = {
4
+ platform: Platform;
5
+ open: boolean;
6
+ port?: number;
7
+ native: boolean;
8
+ };
9
+ /** Parses `run` arguments. Throws a plain message for anything it does not know. */
10
+ export declare function parseRunArgs(argv: string[]): RunOptions;
11
+ /** The command `--native` runs. */
12
+ export declare const nativeCommand: (p: Platform) => {
13
+ cmd: string;
14
+ args: string[];
15
+ };
16
+ export declare const FRAMES: {
17
+ readonly android: {
18
+ readonly width: 412;
19
+ readonly height: 915;
20
+ readonly radius: 36;
21
+ readonly label: "Android";
22
+ readonly sheet: {
23
+ readonly title: "Allow access?";
24
+ readonly allow: "While using the app";
25
+ readonly deny: "Don't allow";
26
+ };
27
+ };
28
+ readonly ios: {
29
+ readonly width: 393;
30
+ readonly height: 852;
31
+ readonly radius: 55;
32
+ readonly label: "iOS";
33
+ readonly sheet: {
34
+ readonly title: "Would Like to Access";
35
+ readonly allow: "Allow While Using App";
36
+ readonly deny: "Don't Allow";
37
+ };
38
+ };
39
+ };
40
+ export type RenderFrame = ((o: {
41
+ skin: "android" | "ios";
42
+ dark?: boolean;
43
+ status?: object;
44
+ screen?: string;
45
+ }) => {
46
+ width: number;
47
+ height: number;
48
+ outerWidth: number;
49
+ outerHeight: number;
50
+ html: string;
51
+ css: string;
52
+ }) & {
53
+ /** Script that keeps the status bar live from the app's `pn:status` events. Absent in older @shipvo/sim. */
54
+ statusScript?: (iframeId?: string, skin?: string) => string;
55
+ };
56
+ /** Loads the frame renderer from @shipvo/sim: the app's install first, then the one next to this CLI. */
57
+ export declare function loadFrameRenderer(bases?: string[]): Promise<RenderFrame>;
58
+ /** 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
+ export declare function runFramePage(platform: Platform, name: string, render: RenderFrame, dark?: boolean, token?: string): string;
60
+ /** Adds `.shipvo/` to `<dir>/.gitignore` when it is not already ignored. */
61
+ export declare function ensureShipvoIgnored(dir: string): void;
62
+ /**
63
+ * Serves the frame page at `/` and the web export at `/app/` (and its absolute asset paths at `/`).
64
+ * Port 0 picks a free port. Resolves once listening.
65
+ * The agent channel is a WebSocket at `/__shipvo/agent?role=screen|driver&token=...`, loopback only. It relays JSON
66
+ * messages between the `screen` and the `driver`. With `projectDir`, `.shipvo/run.json` describes the session while it runs.
67
+ */
68
+ export declare function startRunServer(opts: {
69
+ dir: string;
70
+ platform: Platform;
71
+ name: string;
72
+ port?: number;
73
+ projectDir?: string;
74
+ render?: RenderFrame;
75
+ }): Promise<{
76
+ server: Server;
77
+ port: number;
78
+ token: string;
79
+ close: () => Promise<void>;
80
+ }>;
81
+ /** Names the module or file a web build failed on, from the build's error output. */
82
+ export declare function describeWebBuildFailure(output: string): string;
83
+ /** Builds the web export with `rebuildInto` from sim.ts (the same build as `shipvo sim`) into `<root>/sim-dist`. */
84
+ export declare function buildWeb(root: string, command?: string): Promise<void>;
85
+ /** Returns the exit code. */
86
+ export declare function run(argv: string[]): Promise<number>;
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,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
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, renameSync, rmSync, watch } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { createServer, connect } from "node:net";
7
+ import { ensurePlaywright } from "./ensure-playwright.js";
7
8
  import { args as parse, loadConfig } from "./config.js";
8
9
  /** True if nothing is listening on the port (checks by binding, then closing). */
9
10
  export function portFree(port) {
@@ -14,7 +15,7 @@ export function portFree(port) {
14
15
  });
15
16
  }
16
17
  /** Resolves true once something accepts connections on the port, false if `stop` says to give up. */
17
- function waitListening(port, stop) {
18
+ export function waitListening(port, stop) {
18
19
  return new Promise((resolve) => {
19
20
  const tryOnce = () => {
20
21
  if (stop())
@@ -86,6 +87,7 @@ export function watchAndRebuild(opts) {
86
87
  * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
87
88
  * The old build is moved aside first and moved back if the swap fails.
88
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.
89
91
  */
90
92
  export function rebuildInto(opts = {}) {
91
93
  const root = opts.root ?? process.cwd();
@@ -94,14 +96,19 @@ export function rebuildInto(opts = {}) {
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
+ const p = spawn(`${command} "${next}"`, { cwd: root, shell: true, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "pipe", "pipe"] });
98
100
  let tail = "";
99
- p.stderr.on("data", (d) => { tail = (tail + d).slice(-4000); });
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
+ }
100
106
  p.on("error", (e) => { rmSync(next, { recursive: true, force: true }); reject(e); });
101
107
  p.on("close", (code) => {
102
108
  if (code !== 0) {
103
109
  rmSync(next, { recursive: true, force: true });
104
- return reject(new Error(tail.trim().split("\n").slice(-5).join("\n") || `build exited with code ${code}`));
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 }));
105
112
  }
106
113
  let moved = false;
107
114
  try {
@@ -138,12 +145,19 @@ export async function sim(argv) {
138
145
  console.error(`Building ${cfg.name} for the simulator…`);
139
146
  execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
140
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
+ }
141
155
  let server;
142
156
  try {
143
157
  server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
144
158
  }
145
159
  catch {
146
- 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).");
147
161
  process.exit(2);
148
162
  }
149
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 } });
package/dist/status.d.ts CHANGED
@@ -8,9 +8,57 @@ type AutoHalt = {
8
8
  threshold: number;
9
9
  min_reports: number;
10
10
  } | null;
11
+ type Halted = {
12
+ release: number;
13
+ reports: number;
14
+ failed: number;
15
+ threshold: number;
16
+ };
11
17
  /** Plain-text table: one row per release, one column per event name, then the two medians. */
12
18
  export declare function formatStatus(releases: Row[]): string;
13
19
  /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
14
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
+ }>;
15
63
  export declare function status(argv: string[], fetchFn?: typeof fetch): Promise<number>;
16
64
  export {};
package/dist/status.js CHANGED
@@ -1,4 +1,9 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { routeStore } from "@shipvo/store";
1
4
  import { args, loadConfig } from "./config.js";
5
+ import { detectProject } from "./agents.js";
6
+ import { portFree } from "./sim.js";
2
7
  const ms = (v) => (typeof v === "number" ? String(Math.round(v)) : "-");
3
8
  /** Plain-text table: one row per release, one column per event name, then the two medians. */
4
9
  export function formatStatus(releases) {
@@ -15,8 +20,100 @@ export function describeAutoHalt(c) {
15
20
  const n = c.min_reports ?? 20;
16
21
  return `auto-halt: on at ${Math.round(c.threshold * 1000) / 10}% after ${n} report${n === 1 ? "" : "s"}`;
17
22
  }
23
+ const alive = (pid) => { try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ } };
30
+ /** Local facts for `status --json`: no network. */
31
+ async function localFacts(dir, simPort = 4545) {
32
+ const hasKey = !!(process.env.SHIPVO_SIGNING_SEED || process.env.PURENATIVE_SIGNING_SEED || (process.env.SHIPVO_SIGNING_SEED_FILE && existsSync(process.env.SHIPVO_SIGNING_SEED_FILE)) || (process.env.PURENATIVE_SIGNING_SEED_FILE && existsSync(process.env.PURENATIVE_SIGNING_SEED_FILE)) || existsSync(join(dir, ".shipvo", "seed.txt")));
33
+ let run = { running: false, port: null };
34
+ try {
35
+ const r = JSON.parse(readFileSync(join(dir, ".shipvo", "run.json"), "utf8"));
36
+ if (typeof r.pid === "number" && alive(r.pid))
37
+ run = { running: true, port: typeof r.port === "number" ? r.port : null };
38
+ }
39
+ catch { /* not running */ }
40
+ let storeStep = null;
41
+ try {
42
+ const plan = JSON.parse(readFileSync(join(dir, "store-plan.json"), "utf8"));
43
+ const r = routeStore(plan.answers);
44
+ const step = [...r.play.steps, ...r.apple.steps].find((x) => (plan.marks?.[x.id] ?? "todo") !== "done");
45
+ if (step)
46
+ storeStep = { id: step.id, title: step.title, state: plan.marks?.[step.id] ?? "todo" };
47
+ }
48
+ catch { /* no plan */ }
49
+ return { hasKey, run, storeStep, sim: { running: !(await portFree(simPort)), port: simPort } };
50
+ }
51
+ /** `shipvo status --json`: exit 0 ok, 1 action needed, 2 error. */
52
+ export async function statusJson(appArg, fetchFn, dir = process.cwd()) {
53
+ const kind = detectProject(dir);
54
+ const f = await localFacts(dir);
55
+ const shared = { sim: f.sim, run_server: f.run, signing_key: { present: f.hasKey }, store_step: f.storeStep };
56
+ const mk = (code, summary, next, appId, pending, exit) => ({
57
+ doc: { ok: exit === 0, code, summary, data: { project: { kind, app_id: appId }, ...shared, pending_release: pending }, next }, exit,
58
+ });
59
+ if (!existsSync(join(dir, "shipvo.json"))) {
60
+ return mk("no_project", "No shipvo.json here.", [{ command: kind === "unknown" ? "shipvo init myapp" : "shipvo init .", why: "This folder is not set up for Shipvo yet." }], null, null, 1);
61
+ }
62
+ let cfg;
63
+ try {
64
+ cfg = loadConfig(dir);
65
+ }
66
+ catch (e) {
67
+ return mk("error", e instanceof Error ? e.message : String(e), [{ command: "shipvo doctor", why: "shipvo.json is missing a field." }], null, null, 2);
68
+ }
69
+ const appId = appArg ?? cfg.appId;
70
+ let data;
71
+ const baseUrl = cfg.controlPlane.replace(/\/$/, "");
72
+ try {
73
+ const r = await fetchFn(`${baseUrl}/t/${encodeURIComponent(appId)}/summary`);
74
+ if (!r.ok)
75
+ throw new Error(`HTTP ${r.status}`);
76
+ data = (await r.json());
77
+ }
78
+ catch (e) {
79
+ return mk("error", `Could not reach the control plane at ${baseUrl} (${e instanceof Error ? e.message : String(e)}).`, [{ command: "shipvo doctor", why: "Check the control plane URL and network." }], appId, null, 2);
80
+ }
81
+ const releases = data.releases ?? [];
82
+ const pending = { newest: releases.length ? Math.max(...releases.map((r) => r.release)) : null, halted: data.halted ?? null, auto_halt: data.auto_halt ?? null };
83
+ const next = [];
84
+ let code = "ok", exit = 0;
85
+ let summary = pending.newest === null ? `No releases with telemetry yet for "${appId}".` : `Newest release ${pending.newest} is reporting for "${appId}".`;
86
+ if (!f.hasKey) {
87
+ code = "no_signing_key";
88
+ exit = 1;
89
+ summary = "No signing key found.";
90
+ next.push({ command: "shipvo keygen --out .shipvo/seed.txt", why: "You cannot publish without a signing key." });
91
+ }
92
+ if (pending.halted) {
93
+ code = "release_halted";
94
+ exit = 1;
95
+ summary = `Release ${pending.halted.release} is halted: ${pending.halted.failed} of ${pending.halted.reports} devices failed.`;
96
+ const prev = releases.map((r) => r.release).filter((n) => n < pending.halted.release);
97
+ const to = prev.length ? String(Math.max(...prev)) : "<release>";
98
+ next.unshift({ command: `shipvo rollback --to ${to}`, why: "Send devices back to a release that works, or publish a fixed one." });
99
+ }
100
+ else if (code === "ok" && pending.newest === null) {
101
+ code = "no_release";
102
+ next.push({ command: "shipvo publish --dry-run", why: "Check a first release before you publish it." });
103
+ }
104
+ if (!f.sim.running && !f.run.running)
105
+ next.push({ command: "shipvo sim", why: "Start the simulator to try the app." });
106
+ if (f.storeStep)
107
+ next.push({ command: f.storeStep.state === "waiting" ? "shipvo store status" : `shipvo store status --mark ${f.storeStep.id}`, why: `Store step "${f.storeStep.title}" is ${f.storeStep.state === "todo" ? "next" : f.storeStep.state}.` });
108
+ return mk(code, summary, next, appId, pending, exit);
109
+ }
18
110
  export async function status(argv, fetchFn = fetch) {
19
111
  const a = args(argv);
112
+ if (argv.includes("--json")) {
113
+ const { doc, exit } = await statusJson(a.app && a.app !== "true" ? a.app : undefined, fetchFn);
114
+ console.log(JSON.stringify(doc));
115
+ return exit;
116
+ }
20
117
  const cfg = loadConfig();
21
118
  const appId = a.app && a.app !== "true" ? a.app : cfg.appId;
22
119
  const base = cfg.controlPlane.replace(/\/$/, "");
@@ -32,10 +129,6 @@ export async function status(argv, fetchFn = fetch) {
32
129
  console.error(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
33
130
  return 1;
34
131
  }
35
- if (argv.includes("--json")) {
36
- console.log(JSON.stringify(data));
37
- return 0;
38
- }
39
132
  const releases = data.releases ?? [];
40
133
  // An older control plane has no auto_halt field: say nothing rather than a wrong "off".
41
134
  if ("auto_halt" in data)