@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
@@ -0,0 +1,39 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export declare const NET_PATH = "/__shipvo/net";
3
+ export type NetProfile = {
4
+ latencyMs: number;
5
+ downKbps: number;
6
+ failureRate: number;
7
+ };
8
+ export type NetRecord = {
9
+ at: number;
10
+ session: string;
11
+ method: string;
12
+ host: string;
13
+ status: number;
14
+ ms: number;
15
+ profile: string;
16
+ error?: string;
17
+ };
18
+ /** One cookie jar per phone session, like the cookies of one installed app. */
19
+ export declare class CookieJar {
20
+ private list;
21
+ set(url: URL, header: string, now?: number): void;
22
+ header(url: URL, now?: number): string;
23
+ }
24
+ /** The profile table from @shipvo/sim (one source of truth); a built-in copy if that file cannot be loaded. */
25
+ export declare function loadProfiles(): Promise<Record<string, NetProfile>>;
26
+ export type NetProxy = {
27
+ handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
28
+ log: NetRecord[];
29
+ };
30
+ /** The request handler for `NET_PATH`. `tokens` are the credentials it accepts; `port()` is the bound port. */
31
+ export declare function createNetProxy(o: {
32
+ tokens: string[];
33
+ port: () => number;
34
+ profiles: Record<string, NetProfile>;
35
+ onRecord?: (r: NetRecord) => void;
36
+ fetchImpl?: typeof fetch;
37
+ }): NetProxy;
38
+ /** The script that runs inside the app's page: sends cross-origin fetch and XMLHttpRequest through `NET_PATH`. */
39
+ export declare function netClientSource(): string;
@@ -0,0 +1,266 @@
1
+ import { createRequire } from "node:module";
2
+ import { hostAllowed, isLoopback, originAllowed, tokenMatches } from "./run-protocol.js";
3
+ export const NET_PATH = "/__shipvo/net";
4
+ /** One cookie jar per phone session, like the cookies of one installed app. */
5
+ export class CookieJar {
6
+ list = [];
7
+ set(url, header, now = Date.now()) {
8
+ const [pair, ...attrs] = header.split(";").map((x) => x.trim());
9
+ const eq = pair.indexOf("=");
10
+ if (eq < 1)
11
+ return;
12
+ const c = { name: pair.slice(0, eq), value: pair.slice(eq + 1), domain: url.hostname, hostOnly: true, path: defaultPath(url.pathname), secure: false };
13
+ for (const a of attrs) {
14
+ const i = a.indexOf("=");
15
+ const k = (i < 0 ? a : a.slice(0, i)).toLowerCase();
16
+ const v = i < 0 ? "" : a.slice(i + 1);
17
+ if (k === "domain" && v) {
18
+ c.domain = v.replace(/^\./, "").toLowerCase();
19
+ c.hostOnly = false;
20
+ }
21
+ else if (k === "path" && v.startsWith("/"))
22
+ c.path = v;
23
+ else if (k === "secure")
24
+ c.secure = true;
25
+ else if (k === "expires" && c.expires === undefined) {
26
+ const t = Date.parse(v);
27
+ if (!Number.isNaN(t))
28
+ c.expires = t;
29
+ }
30
+ else if (k === "max-age" && /^-?\d+$/.test(v))
31
+ c.expires = now + Number(v) * 1000;
32
+ }
33
+ if (!c.hostOnly && !(url.hostname === c.domain || url.hostname.endsWith("." + c.domain)))
34
+ return;
35
+ this.list = this.list.filter((o) => !(o.name === c.name && o.domain === c.domain && o.path === c.path));
36
+ if (c.expires === undefined || c.expires > now)
37
+ this.list.push(c);
38
+ }
39
+ header(url, now = Date.now()) {
40
+ this.list = this.list.filter((c) => c.expires === undefined || c.expires > now);
41
+ return this.list
42
+ .filter((c) => (c.hostOnly ? url.hostname === c.domain : url.hostname === c.domain || url.hostname.endsWith("." + c.domain)) && url.pathname.startsWith(c.path) && (!c.secure || url.protocol === "https:"))
43
+ .sort((a, b) => b.path.length - a.path.length)
44
+ .map((c) => `${c.name}=${c.value}`).join("; ");
45
+ }
46
+ }
47
+ const defaultPath = (p) => { const i = p.lastIndexOf("/"); return i <= 0 ? "/" : p.slice(0, i); };
48
+ const FALLBACK = {
49
+ online: { latencyMs: 0, downKbps: Infinity, failureRate: 0 }, offline: { latencyMs: 0, downKbps: 0, failureRate: 1 },
50
+ "2g": { latencyMs: 800, downKbps: 50, failureRate: 0.02 }, "3g": { latencyMs: 300, downKbps: 750, failureRate: 0.01 },
51
+ "slow-4g": { latencyMs: 150, downKbps: 1600, failureRate: 0 }, spotty: { latencyMs: 400, downKbps: 500, failureRate: 0.3 },
52
+ "low-end-4g": { latencyMs: 100, downKbps: 6300, failureRate: 0.005 },
53
+ };
54
+ /** The profile table from @shipvo/sim (one source of truth); a built-in copy if that file cannot be loaded. */
55
+ export async function loadProfiles() {
56
+ try {
57
+ const file = createRequire(import.meta.url).resolve("@shipvo/sim/network-profiles.mjs");
58
+ const mod = await import(new URL(`file://${file.startsWith("/") ? "" : "/"}${file.replace(/\\/g, "/")}`).href);
59
+ return mod.PROFILES;
60
+ }
61
+ catch {
62
+ return FALLBACK;
63
+ }
64
+ }
65
+ const HOP = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "te", "trailer", "content-length", "content-encoding", "set-cookie", "host"]);
66
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
67
+ const readBody = (req) => new Promise((ok, fail) => { const b = []; req.on("data", (c) => b.push(c)); req.on("end", () => ok(Buffer.concat(b))); req.on("error", fail); });
68
+ const hostOf = (u) => u.host;
69
+ /** The request handler for `NET_PATH`. `tokens` are the credentials it accepts; `port()` is the bound port. */
70
+ export function createNetProxy(o) {
71
+ const jars = new Map();
72
+ const log = [];
73
+ const doFetch = o.fetchImpl ?? fetch;
74
+ let draw = 1;
75
+ const random = () => { draw = (Math.imul(draw, 1664525) + 1013904223) >>> 0; return draw / 4294967296; };
76
+ const refuse = (res, code, msg) => { res.writeHead(code, { "content-type": "text/plain" }); res.end(msg); };
77
+ async function handle(req, res) {
78
+ const port = o.port();
79
+ if (!isLoopback(req.socket.remoteAddress) || !hostAllowed(req.headers.host, port) || req.headers.origin === undefined || !originAllowed(req.headers.origin, port))
80
+ return refuse(res, 403, "Forbidden");
81
+ const given = req.headers["x-shipvo-token"];
82
+ if (typeof given !== "string" || !o.tokens.some((t) => tokenMatches(given, t)))
83
+ return refuse(res, 401, "Unauthorized");
84
+ if (req.method !== "POST")
85
+ return refuse(res, 405, "Use POST");
86
+ let spec;
87
+ try {
88
+ spec = JSON.parse(decodeURIComponent(String(req.headers["x-shipvo-req"])));
89
+ new URL(spec.url);
90
+ }
91
+ catch {
92
+ return refuse(res, 400, "Bad request description");
93
+ }
94
+ const url = new URL(spec.url);
95
+ if (url.protocol !== "http:" && url.protocol !== "https:")
96
+ return refuse(res, 400, "Only http and https");
97
+ const session = String(req.headers["x-shipvo-session"] ?? "default").slice(0, 64);
98
+ const profileName = String(req.headers["x-shipvo-profile"] ?? "online");
99
+ const profile = o.profiles[profileName] ?? o.profiles.online;
100
+ const method = String(spec.method || "GET").toUpperCase();
101
+ const started = Date.now();
102
+ const record = (status, error) => { const r = { at: started, session, method, host: hostOf(url), status, ms: Date.now() - started, profile: profileName, ...(error ? { error } : {}) }; log.push(r); o.onRecord?.(r); };
103
+ const body = await readBody(req);
104
+ const failed = profile.failureRate >= 1 || random() < profile.failureRate;
105
+ const bytesMs = (n) => (n && Number.isFinite(profile.downKbps) && profile.downKbps > 0 ? (n * 8) / profile.downKbps : 0);
106
+ await sleep(profile.latencyMs + bytesMs(body.length));
107
+ // XMLHttpRequest cannot tell a 599 from a reply, so drop the connection and let the browser raise its own `error` event.
108
+ const dropForXhr = () => { if (req.headers["x-shipvo-xhr"]) {
109
+ req.socket.destroy();
110
+ return true;
111
+ } return false; };
112
+ if (failed) {
113
+ record(599, "network request failed");
114
+ if (dropForXhr())
115
+ return;
116
+ res.writeHead(599, { "x-shipvo-net-error": "1", "content-type": "text/plain" });
117
+ return void res.end("network request failed");
118
+ }
119
+ let jar = jars.get(session);
120
+ if (!jar)
121
+ jars.set(session, jar = new CookieJar());
122
+ const headers = new Headers();
123
+ for (const [k, v] of Object.entries(spec.headers ?? {}))
124
+ if (!HOP.has(k.toLowerCase()) && !["cookie", "origin", "referer"].includes(k.toLowerCase()))
125
+ headers.set(k, v);
126
+ let up;
127
+ let cur = url;
128
+ let curMethod = method;
129
+ let curBody = method === "GET" || method === "HEAD" || body.length === 0 ? undefined : new Uint8Array(body);
130
+ try {
131
+ // Redirects are followed here so each hop's Set-Cookie reaches the jar and its cookies go out on the next hop.
132
+ for (let hop = 0;; hop++) {
133
+ const h = new Headers(headers);
134
+ h.delete("cookie");
135
+ const c = jar.header(cur);
136
+ if (c)
137
+ h.set("cookie", c);
138
+ up = await doFetch(cur, { method: curMethod, headers: h, body: curBody, redirect: "manual" });
139
+ for (const sc of up.headers.getSetCookie())
140
+ jar.set(cur, sc);
141
+ const loc = up.headers.get("location");
142
+ if (![301, 302, 303, 307, 308].includes(up.status) || !loc)
143
+ break;
144
+ if (hop >= 20)
145
+ throw new Error("too many redirects");
146
+ const next = new URL(loc, cur);
147
+ if (next.protocol !== "http:" && next.protocol !== "https:")
148
+ throw new Error("redirect to an unsupported address");
149
+ void up.body?.cancel().catch(() => { });
150
+ if (next.origin !== cur.origin)
151
+ headers.delete("authorization");
152
+ if (up.status === 303 ? curMethod !== "HEAD" : (up.status === 301 || up.status === 302) && curMethod === "POST") {
153
+ curMethod = "GET";
154
+ curBody = undefined;
155
+ }
156
+ cur = next;
157
+ }
158
+ }
159
+ catch (e) {
160
+ record(599, e.message);
161
+ if (dropForXhr())
162
+ return;
163
+ res.writeHead(599, { "x-shipvo-net-error": "1", "content-type": "text/plain" });
164
+ return void res.end(`network request failed: ${e.message}`);
165
+ }
166
+ const out = { "x-shipvo-final-url": cur.href, "cache-control": "no-store" };
167
+ up.headers.forEach((v, k) => { if (!HOP.has(k))
168
+ out[k] = v; });
169
+ res.writeHead(up.status, out);
170
+ record(up.status);
171
+ if (!up.body || method === "HEAD")
172
+ return void res.end();
173
+ res.on("close", () => { void up.body?.cancel().catch(() => { }); });
174
+ const reader = up.body.getReader();
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ const wait = bytesMs(value.length);
180
+ if (wait > 0)
181
+ await sleep(wait);
182
+ if (!res.write(value))
183
+ await new Promise((r) => res.once("drain", r));
184
+ }
185
+ res.end();
186
+ }
187
+ return { handle: (req, res) => handle(req, res).catch(() => { if (!res.headersSent)
188
+ refuse(res, 500, "Proxy error");
189
+ else
190
+ res.destroy(); }), log };
191
+ }
192
+ /** The script that runs inside the app's page: sends cross-origin fetch and XMLHttpRequest through `NET_PATH`. */
193
+ export function netClientSource() { return `(${netClientMain.toString()})`; }
194
+ function netClientMain(win, cfg) {
195
+ const origin = win.location.origin;
196
+ const abs = (u) => new win.URL(u, win.location.href);
197
+ const proxied = (u) => { try {
198
+ const x = abs(String(u));
199
+ return (x.protocol === "http:" || x.protocol === "https:") && x.origin !== origin ? x : null;
200
+ }
201
+ catch {
202
+ return null;
203
+ } };
204
+ const meta = (url, method, headers) => ({
205
+ "x-shipvo-token": cfg.token, "x-shipvo-session": cfg.session, "x-shipvo-profile": cfg.profile(),
206
+ "x-shipvo-req": encodeURIComponent(JSON.stringify({ url: url.href, method, headers })),
207
+ });
208
+ const origFetch = win.fetch.bind(win);
209
+ win.fetch = async function (input, init) {
210
+ const isReq = input instanceof win.Request;
211
+ // Decide before building a Request: building one from a Request with a body would use up the original.
212
+ if (!proxied(isReq ? input.url : input))
213
+ return origFetch(input, init);
214
+ const req = new win.Request(input, init);
215
+ const url = proxied(req.url);
216
+ if (!url)
217
+ return origFetch(input, init);
218
+ const t0 = Date.now();
219
+ const headers = {};
220
+ req.headers.forEach((v, k) => { headers[k] = v; });
221
+ const fail = () => { cfg.log({ method: req.method, host: url.host, status: 0, ms: Date.now() - t0 }); throw new TypeError("Network request failed"); };
222
+ if (!cfg.online())
223
+ fail();
224
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
225
+ let res;
226
+ try {
227
+ res = await origFetch("/__shipvo/net", { method: "POST", headers: meta(url, req.method, headers), body, signal: req.signal, cache: "no-store" });
228
+ }
229
+ catch {
230
+ fail();
231
+ }
232
+ if (res.headers.get("x-shipvo-net-error"))
233
+ fail();
234
+ cfg.log({ method: req.method, host: url.host, status: res.status, ms: Date.now() - t0 });
235
+ return res;
236
+ };
237
+ const X = win.XMLHttpRequest;
238
+ if (X) {
239
+ const open = X.prototype.open, send = X.prototype.send, setH = X.prototype.setRequestHeader;
240
+ X.prototype.open = function (method, u, ...rest) {
241
+ const url = proxied(u);
242
+ this.__net = url ? { url, method: String(method).toUpperCase(), headers: {} } : undefined;
243
+ return this.__net ? open.call(this, "POST", "/__shipvo/net", ...rest) : open.call(this, method, u, ...rest);
244
+ };
245
+ X.prototype.setRequestHeader = function (k, v) { if (this.__net) {
246
+ this.__net.headers[k] = v;
247
+ return;
248
+ } return setH.call(this, k, v); };
249
+ X.prototype.send = function (body) {
250
+ const n = this.__net;
251
+ if (!n)
252
+ return send.call(this, body);
253
+ const t0 = Date.now();
254
+ if (!cfg.online()) {
255
+ cfg.log({ method: n.method, host: n.url.host, status: 0, ms: 0 });
256
+ win.setTimeout(() => { this.dispatchEvent(new win.ProgressEvent("error")); }, 0);
257
+ return;
258
+ }
259
+ const m = { ...meta(n.url, n.method, n.headers), "x-shipvo-xhr": "1" };
260
+ for (const k of Object.keys(m))
261
+ setH.call(this, k, m[k]);
262
+ this.addEventListener("loadend", () => cfg.log({ method: n.method, host: n.url.host, status: this.status, ms: Date.now() - t0 }));
263
+ return send.call(this, n.method === "GET" || n.method === "HEAD" ? null : body);
264
+ };
265
+ }
266
+ }
@@ -0,0 +1,32 @@
1
+ export declare const AGENT_PATH = "/__shipvo/agent";
2
+ export type AgentRole = "screen" | "driver";
3
+ /** Contents of `.shipvo/run.json` while `shipvo run` is up. */
4
+ export type RunInfo = {
5
+ port: number;
6
+ platform: "android" | "ios";
7
+ app: string;
8
+ token: string;
9
+ pid: number;
10
+ };
11
+ /** Driver to screen: ask the page to do something. `id` pairs the reply with the request. */
12
+ export type DriverMessage = {
13
+ id: number;
14
+ type: string;
15
+ [key: string]: unknown;
16
+ };
17
+ /** Screen to driver: the reply to `id`, or an event the page pushes on its own (no `id`). */
18
+ export type ScreenMessage = {
19
+ id?: number;
20
+ type: string;
21
+ ok?: boolean;
22
+ error?: string;
23
+ [key: string]: unknown;
24
+ };
25
+ export type AgentMessage = DriverMessage | ScreenMessage;
26
+ export declare const isAgentRole: (v: string | null) => v is AgentRole;
27
+ export declare function isLoopback(addr: string | undefined): boolean;
28
+ /** True when the `Host` header names this server on loopback. Blocks DNS rebinding. */
29
+ export declare function hostAllowed(host: string | undefined, port: number): boolean;
30
+ /** True when there is no `Origin` (not a browser) or it is this server's own origin. */
31
+ export declare function originAllowed(origin: string | undefined, port: number): boolean;
32
+ export declare function tokenMatches(given: string | null, token: string): boolean;
@@ -0,0 +1,22 @@
1
+ // Messages on the `shipvo run` agent channel (`/__shipvo/agent`). The host only relays them between the
2
+ // `screen` (the browser tab) and the `driver` (the agent). Slices 2 and 3 of #448 build on these types.
3
+ import { timingSafeEqual } from "node:crypto";
4
+ export const AGENT_PATH = "/__shipvo/agent";
5
+ export const isAgentRole = (v) => v === "screen" || v === "driver";
6
+ export function isLoopback(addr) {
7
+ return addr === "127.0.0.1" || addr === "::1" || (addr?.startsWith("::ffff:127.") ?? false);
8
+ }
9
+ /** True when the `Host` header names this server on loopback. Blocks DNS rebinding. */
10
+ export function hostAllowed(host, port) {
11
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
12
+ }
13
+ /** True when there is no `Origin` (not a browser) or it is this server's own origin. */
14
+ export function originAllowed(origin, port) {
15
+ return origin === undefined || origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
16
+ }
17
+ export function tokenMatches(given, token) {
18
+ if (!given)
19
+ return false;
20
+ const a = Buffer.from(given), b = Buffer.from(token);
21
+ return a.length === b.length && timingSafeEqual(a, b);
22
+ }
@@ -0,0 +1,8 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { Duplex } from "node:stream";
3
+ export type Peer = {
4
+ send(text: string): void;
5
+ close(): void;
6
+ };
7
+ /** Completes the handshake and returns a peer. `onText` gets each complete text message; `onClose` fires once. */
8
+ export declare function acceptWebSocket(req: IncomingMessage, socket: Duplex, onText: (t: string) => void, onClose: () => void): Peer | undefined;
package/dist/run-ws.js ADDED
@@ -0,0 +1,89 @@
1
+ // A small server-side WebSocket (RFC 6455) on `node:http` upgrade, enough for JSON text messages.
2
+ import { createHash } from "node:crypto";
3
+ const MAX = 1 << 20;
4
+ function frame(op, payload) {
5
+ const n = payload.length;
6
+ const head = n < 126 ? Buffer.from([0x80 | op, n]) : n < 65536 ? Buffer.from([0x80 | op, 126, n >> 8, n & 255]) : (() => {
7
+ const h = Buffer.alloc(10);
8
+ h[0] = 0x80 | op;
9
+ h[1] = 127;
10
+ h.writeBigUInt64BE(BigInt(n), 2);
11
+ return h;
12
+ })();
13
+ return Buffer.concat([head, payload]);
14
+ }
15
+ /** Completes the handshake and returns a peer. `onText` gets each complete text message; `onClose` fires once. */
16
+ export function acceptWebSocket(req, socket, onText, onClose) {
17
+ const key = req.headers["sec-websocket-key"];
18
+ if (typeof key !== "string" || req.headers.upgrade?.toLowerCase() !== "websocket") {
19
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
20
+ return undefined;
21
+ }
22
+ const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
23
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
24
+ let buf = Buffer.alloc(0);
25
+ let parts = [];
26
+ let closed = false;
27
+ const done = () => { if (!closed) {
28
+ closed = true;
29
+ onClose();
30
+ } };
31
+ const shut = () => { if (!closed)
32
+ socket.write(frame(8, Buffer.alloc(0))); socket.end(); done(); };
33
+ socket.on("close", done);
34
+ socket.on("error", () => { socket.destroy(); done(); });
35
+ socket.on("data", (chunk) => {
36
+ buf = Buffer.concat([buf, chunk]);
37
+ for (;;) {
38
+ if (buf.length < 2)
39
+ return;
40
+ const op = buf[0] & 15, fin = (buf[0] & 128) !== 0, masked = (buf[1] & 128) !== 0;
41
+ let len = buf[1] & 127, off = 2;
42
+ if (len === 126) {
43
+ if (buf.length < 4)
44
+ return;
45
+ len = buf.readUInt16BE(2);
46
+ off = 4;
47
+ }
48
+ else if (len === 127) {
49
+ if (buf.length < 10)
50
+ return;
51
+ const big = buf.readBigUInt64BE(2);
52
+ if (big > BigInt(MAX))
53
+ return shut();
54
+ len = Number(big);
55
+ off = 10;
56
+ }
57
+ if (!masked || len > MAX)
58
+ return shut(); // clients must mask
59
+ if (buf.length < off + 4 + len)
60
+ return;
61
+ const mask = buf.subarray(off, off + 4);
62
+ const data = Buffer.from(buf.subarray(off + 4, off + 4 + len));
63
+ for (let i = 0; i < len; i++)
64
+ data[i] ^= mask[i & 3];
65
+ buf = buf.subarray(off + 4 + len);
66
+ if (op === 8)
67
+ return shut();
68
+ if (op === 9) {
69
+ socket.write(frame(10, data));
70
+ continue;
71
+ }
72
+ if (op === 10)
73
+ continue;
74
+ if (op === 1 || op === 2 || op === 0) {
75
+ parts.push(data);
76
+ if (parts.reduce((s, p) => s + p.length, 0) > MAX)
77
+ return shut();
78
+ if (fin) {
79
+ const text = Buffer.concat(parts).toString("utf8");
80
+ parts = [];
81
+ if (op !== 2)
82
+ onText(text);
83
+ }
84
+ }
85
+ }
86
+ });
87
+ return { send: (t) => { if (!closed)
88
+ socket.write(frame(1, Buffer.from(t))); }, close: shut };
89
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { networkInterfaces } from "node:os";
2
+ import { type Server } from "node:http";
3
+ import { type NetRecord } from "./run-net.js";
4
+ export type Platform = "android" | "ios";
5
+ export type RunOptions = {
6
+ platform: Platform;
7
+ open: boolean;
8
+ port?: number;
9
+ native: boolean;
10
+ lan: boolean;
11
+ };
12
+ export declare const RUN_USAGE = "Usage: shipvo run android|ios [options]\n\nOptions:\n --no-open do not open the browser\n --port <n> serve on this port (default: a free port)\n --native run on a real emulator or simulator with `npx expo run:<platform>`\n\nExample: shipvo run android";
13
+ /** True when this project has no earlier web build, so Metro starts with an empty cache. */
14
+ export declare const isFirstBuild: (root: string) => boolean;
15
+ /** Parses `run` arguments. Throws a plain message for anything it does not know. */
16
+ export declare function parseRunArgs(argv: string[]): RunOptions;
17
+ /** The command `--native` runs. */
18
+ export declare const nativeCommand: (p: Platform) => {
19
+ cmd: string;
20
+ args: string[];
21
+ };
22
+ export declare const FRAMES: {
23
+ readonly android: {
24
+ readonly width: 412;
25
+ readonly height: 915;
26
+ readonly radius: 36;
27
+ readonly label: "Android";
28
+ readonly sheet: {
29
+ readonly title: "Allow access?";
30
+ readonly allow: "While using the app";
31
+ readonly deny: "Don't allow";
32
+ };
33
+ };
34
+ readonly ios: {
35
+ readonly width: 393;
36
+ readonly height: 852;
37
+ readonly radius: 55;
38
+ readonly label: "iOS";
39
+ readonly sheet: {
40
+ readonly title: "Would Like to Access";
41
+ readonly allow: "Allow While Using App";
42
+ readonly deny: "Don't Allow";
43
+ };
44
+ };
45
+ };
46
+ export type RenderFrame = ((o: {
47
+ skin: "android" | "ios";
48
+ dark?: boolean;
49
+ status?: object;
50
+ screen?: string;
51
+ lumi?: boolean;
52
+ guide?: boolean;
53
+ }) => {
54
+ width: number;
55
+ height: number;
56
+ outerWidth: number;
57
+ outerHeight: number;
58
+ html: string;
59
+ css: string;
60
+ }) & {
61
+ /** Script that keeps the status bar live from the app's `pn:status` events. Absent in older @shipvo/sim. */
62
+ statusScript?: (iframeId?: string, skin?: string) => string;
63
+ };
64
+ /** Loads the frame renderer from @shipvo/sim: the app's install first, then the one next to this CLI. */
65
+ export declare function loadFrameRenderer(bases?: string[]): Promise<RenderFrame>;
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 declare function runFramePage(platform: Platform, name: string, render: RenderFrame, dark?: boolean, token?: string): string;
68
+ /** True for a phone or tablet browser, by user agent. */
69
+ export declare const isMobileUserAgent: (ua: string | undefined) => boolean;
70
+ /** The page for a real phone's browser: the app fills the screen, no phone frame. */
71
+ export declare function runBarePage(name: string, dark?: boolean): string;
72
+ /** The first private IPv4 address of this machine (or any non-internal one), or undefined. */
73
+ export declare function lanAddress(ifaces?: ReturnType<typeof networkInterfaces>): string | undefined;
74
+ /** Adds `.shipvo/` to `<dir>/.gitignore` when it is not already ignored. */
75
+ export declare function ensureShipvoIgnored(dir: string): void;
76
+ /**
77
+ * Serves the frame page at `/` and the web export at `/app/` (and its absolute asset paths at `/`).
78
+ * Port 0 picks a free port. Resolves once listening.
79
+ * The agent channel is a WebSocket at `/__shipvo/agent?role=screen|driver&token=...`, loopback only. It relays JSON
80
+ * messages between the `screen` and the `driver`. With `projectDir`, `.shipvo/run.json` describes the session while it runs.
81
+ */
82
+ export declare function startRunServer(opts: {
83
+ dir: string;
84
+ platform: Platform;
85
+ name: string;
86
+ appId?: string;
87
+ port?: number;
88
+ projectDir?: string;
89
+ render?: RenderFrame;
90
+ lan?: {
91
+ host: string;
92
+ };
93
+ onNet?: (r: NetRecord) => void;
94
+ fetchImpl?: typeof fetch;
95
+ }): Promise<{
96
+ server: Server;
97
+ port: number;
98
+ token: string;
99
+ lanToken?: string;
100
+ netLog: NetRecord[];
101
+ close: () => Promise<void>;
102
+ }>;
103
+ /** Names the module or file a web build failed on, from the build's error output. */
104
+ export declare function describeWebBuildFailure(output: string): string;
105
+ /** Builds the web export with `rebuildInto` from sim.ts (the same build as `shipvo sim`) into `<root>/sim-dist`. */
106
+ export declare function buildWeb(root: string, command?: string): Promise<void>;
107
+ export declare function openBrowser(url: string): void;
108
+ /** Returns the exit code. */
109
+ export declare function run(argv: string[]): Promise<number>;