@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
@@ -0,0 +1,374 @@
1
+ // The page script of `shipvo run`. The frame page serves it at `/__shipvo/bridge.js`. It connects to the agent
2
+ // channel as role `screen` and answers `tree` requests from the same-origin app iframe. No Playwright: the tree is
3
+ // built from the DOM, in the shape of the simulator's `- role "name" [ref=eN]` lines (slice 3b of #448).
4
+ // Differences from the simulator tree are listed in docs/run-bridge.md.
5
+ import { readFileSync } from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ /**
8
+ * The shared screen rules (`@shipvo/sim`), as source that the page can run. Same copy the simulator uses.
9
+ * Only plain `export function` / `export const` lines are supported: `export default` and `export {…}` would not be stripped.
10
+ */
11
+ export function screenRulesSource() {
12
+ const path = createRequire(import.meta.url).resolve("@shipvo/sim/screen-rules.mjs");
13
+ const src = readFileSync(path, "utf8");
14
+ if (/^export\s+(default\b|\{|\*)/m.test(src))
15
+ throw new Error("screen-rules.mjs may only use `export function` or `export const`");
16
+ return src.replace(/^export /gm, "");
17
+ }
18
+ /** Runs in the page. Everything it needs is nested inside, because it is serialised with `String(fn)`. */
19
+ function bridgeMain(win, token) {
20
+ const doc = win.document;
21
+ // A ref names one element with one role and name. If the element's content changes, it gets a new ref and the old one is stale.
22
+ const refs = new WeakMap();
23
+ const byRef = new Map();
24
+ let counter = 0;
25
+ const refOf = (el, sig) => {
26
+ let r = refs.get(el);
27
+ if (!r || r.sig !== sig) {
28
+ r = { n: ++counter, sig };
29
+ refs.set(el, r);
30
+ }
31
+ byRef.set("e" + r.n, { el, sig });
32
+ return "e" + r.n;
33
+ };
34
+ const INPUT_ROLE = { checkbox: "checkbox", radio: "radio", range: "slider", button: "button", submit: "button", reset: "button", image: "button", search: "searchbox", number: "spinbutton" };
35
+ const TAG_ROLE = { button: "button", textarea: "textbox", select: "combobox", img: "img", ul: "list", ol: "list", li: "listitem", nav: "navigation", main: "main", dialog: "dialog", form: "form", table: "table", tr: "row", td: "cell", th: "columnheader", p: "paragraph", progress: "progressbar", article: "article", aside: "complementary" };
36
+ const FROM_CONTENT = new Set(["button", "link", "heading", "tab", "checkbox", "radio", "switch", "menuitem", "option", "cell", "columnheader"]);
37
+ const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim();
38
+ const hidden = (el, cs) => el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true" || cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse";
39
+ function roleOf(el) {
40
+ const explicit = norm(el.getAttribute("role")).split(" ")[0];
41
+ if (explicit && explicit !== "presentation" && explicit !== "none")
42
+ return explicit;
43
+ const tag = el.tagName.toLowerCase();
44
+ if (tag === "a")
45
+ return el.hasAttribute("href") ? "link" : "";
46
+ if (/^h[1-6]$/.test(tag))
47
+ return "heading";
48
+ if (tag === "input") {
49
+ const t = (el.getAttribute("type") || "text").toLowerCase();
50
+ return t === "hidden" ? "" : INPUT_ROLE[t] || "textbox";
51
+ }
52
+ if (tag === "section")
53
+ return el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") ? "region" : "";
54
+ if (tag === "img" && el.getAttribute("alt") === "")
55
+ return "";
56
+ return TAG_ROLE[tag] || "";
57
+ }
58
+ function textOf(node) {
59
+ if (node.nodeType === 3)
60
+ return node.nodeValue || "";
61
+ if (node.nodeType !== 1)
62
+ return "";
63
+ const tag = node.tagName.toLowerCase();
64
+ if (tag === "script" || tag === "style" || tag === "noscript")
65
+ return "";
66
+ if (hidden(node, win.getComputedStyle(node)))
67
+ return "";
68
+ if (tag === "img")
69
+ return node.getAttribute("alt") || "";
70
+ const own = node.getAttribute("aria-label");
71
+ if (own && roleOf(node))
72
+ return own;
73
+ let out = "";
74
+ for (const c of node.childNodes)
75
+ out += textOf(c) + (c.nodeType === 1 && win.getComputedStyle(c).display !== "inline" ? " " : "");
76
+ return out;
77
+ }
78
+ /** Accessible name and whether it came from the element's own content. */
79
+ function nameOf(el, role) {
80
+ const by = norm(el.getAttribute("aria-labelledby"));
81
+ if (by) {
82
+ const t = by.split(" ").map((id) => { const r = doc.getElementById(id); return r ? norm(textOf(r)) : ""; }).filter(Boolean).join(" ");
83
+ if (t)
84
+ return { name: t, fromContent: false };
85
+ }
86
+ const label = norm(el.getAttribute("aria-label"));
87
+ if (label)
88
+ return { name: label, fromContent: false };
89
+ const tag = el.tagName.toLowerCase();
90
+ if (tag === "input" || tag === "textarea" || tag === "select") {
91
+ const ls = el.labels ? [...el.labels] : [];
92
+ const t = norm(ls.map((l) => textOf(l)).join(" "));
93
+ if (t)
94
+ return { name: t, fromContent: false };
95
+ if (tag === "input" && /^(button|submit|reset)$/.test(el.type) && el.value)
96
+ return { name: norm(el.value), fromContent: false };
97
+ const ph = norm(el.getAttribute("placeholder")) || norm(el.getAttribute("title"));
98
+ return { name: ph, fromContent: false };
99
+ }
100
+ if (tag === "img")
101
+ return { name: norm(el.getAttribute("alt")) || norm(el.getAttribute("title")), fromContent: false };
102
+ if (FROM_CONTENT.has(role)) {
103
+ const t = norm(textOf(el));
104
+ if (t)
105
+ return { name: t, fromContent: true };
106
+ }
107
+ return { name: norm(el.getAttribute("title")), fromContent: false };
108
+ }
109
+ function states(el, role) {
110
+ let s = "";
111
+ const aria = (n) => el.getAttribute(n);
112
+ const on = (n) => aria(n) === "true";
113
+ if (role === "heading") {
114
+ const l = aria("aria-level") || (/^h([1-6])$/i.exec(el.tagName)?.[1] ?? "2");
115
+ s += " [level=" + l + "]";
116
+ }
117
+ if (/^(checkbox|radio|switch|menuitemcheckbox|menuitemradio)$/.test(role)) {
118
+ if (el.tagName === "INPUT" ? el.checked : on("aria-checked"))
119
+ s += " [checked]";
120
+ else if (aria("aria-checked") === "mixed")
121
+ s += " [checked=mixed]";
122
+ }
123
+ if (el.disabled === true || on("aria-disabled") || (el.closest && el.closest("fieldset[disabled]")))
124
+ s += " [disabled]";
125
+ if (on("aria-expanded"))
126
+ s += " [expanded]";
127
+ if (/^(tab|option|treeitem|row|gridcell)$/.test(role) && on("aria-selected"))
128
+ s += " [selected]";
129
+ if (on("aria-pressed"))
130
+ s += " [pressed]";
131
+ return s;
132
+ }
133
+ const quote = (s) => JSON.stringify(s);
134
+ // The simulator's snapshot is YAML-like: a key with ": " inside is wrapped in single quotes, a value with ": " in double quotes.
135
+ const needs = (t) => /: | #|:$|^[\[\]{}&*!|>'"%@`#?,-]/.test(t);
136
+ const keyLine = (pad, key, tail) => pad + "- " + (needs(key) ? "'" + key.replace(/'/g, "''") + "'" : key) + tail;
137
+ const val = (t) => (needs(t) ? JSON.stringify(t) : t);
138
+ function walk(el, depth, out) {
139
+ const tag = el.tagName.toLowerCase();
140
+ if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template")
141
+ return;
142
+ if (hidden(el, win.getComputedStyle(el)))
143
+ return;
144
+ const role = roleOf(el);
145
+ const pad = " ".repeat(depth);
146
+ if (role) {
147
+ const { name, fromContent } = nameOf(el, role);
148
+ const key = role + (name ? " " + quote(name) : "") + states(el, role) + " [ref=" + refOf(el, role + "|" + name) + "]";
149
+ if (tag === "input" || tag === "textarea") {
150
+ const v = el.value;
151
+ out.push(keyLine(pad, key, v && !/^(checkbox|radio|button|submit|reset)$/.test(el.type) ? ": " + val(v) : ""));
152
+ return;
153
+ }
154
+ if (fromContent || tag === "img") {
155
+ out.push(keyLine(pad, key, ""));
156
+ return;
157
+ }
158
+ const mark = out.length;
159
+ out.push("");
160
+ for (const c of el.childNodes)
161
+ child(c, depth + 1, out);
162
+ out[mark] = keyLine(pad, key, out.length > mark + 1 ? ":" : "");
163
+ return;
164
+ }
165
+ // No role: keep the text, drop the wrapper. A wrapper with only text becomes one generic line.
166
+ const hasEl = [...el.children].length > 0;
167
+ if (!hasEl) {
168
+ const t = norm(el.textContent);
169
+ if (t)
170
+ out.push(pad + "- generic [ref=" + refOf(el, "generic|" + t) + "]: " + val(t));
171
+ return;
172
+ }
173
+ for (const c of el.childNodes)
174
+ child(c, depth, out);
175
+ }
176
+ function child(c, depth, out) {
177
+ if (c.nodeType === 1)
178
+ walk(c, depth, out);
179
+ else if (c.nodeType === 3) {
180
+ const t = norm(c.nodeValue);
181
+ if (t)
182
+ out.push(" ".repeat(depth) + "- text: " + val(t));
183
+ }
184
+ }
185
+ function tree() {
186
+ const frame = doc.getElementById("app");
187
+ const d = frame && frame.contentDocument;
188
+ if (!d || !d.body)
189
+ throw new Error("the app is not loaded yet");
190
+ globalThis.__shipvoRules.clearScreenRules(d);
191
+ globalThis.__shipvoRules.applyScreenRules(d);
192
+ try {
193
+ const out = [];
194
+ for (const c of d.body.childNodes)
195
+ child(c, 0, out);
196
+ return globalThis.__shipvoRules.filterTree(out.join("\n"));
197
+ }
198
+ finally {
199
+ globalThis.__shipvoRules.clearScreenRules(d);
200
+ }
201
+ }
202
+ /** Same code and message shape as the simulator's typed errors. */
203
+ class ActError extends Error {
204
+ code;
205
+ constructor(code, msg) { super(msg); this.code = code; }
206
+ }
207
+ const sigOf = (el) => { const role = roleOf(el); return role ? role + "|" + nameOf(el, role).name : "generic|" + norm(el.textContent); };
208
+ const appDoc = () => { const f = doc.getElementById("app"); return f && f.contentDocument; };
209
+ /** The element for a ref from a snapshot, or `stale_ref` if it is unknown, gone, left the page, or changed role or name. */
210
+ function resolve(ref) {
211
+ const stale = () => new ActError("stale_ref", `stale ref ${ref}: the screen changed. Take a new snapshot (action "screen") and use a ref from it.`);
212
+ const d = appDoc();
213
+ const rec = byRef.get(String(ref));
214
+ if (!d || !rec || !rec.el.isConnected || rec.el.ownerDocument !== d)
215
+ throw stale();
216
+ if (sigOf(rec.el) !== rec.sig || refs.get(rec.el)?.sig !== rec.sig)
217
+ throw stale();
218
+ return rec.el;
219
+ }
220
+ /** Say why an element cannot receive a tap. Nothing has reached the app when this throws. */
221
+ function ready(el, ref) {
222
+ const fail = (code, why) => new ActError(code, `${ref} ${why}, so nothing was sent to the app.`);
223
+ if (hidden(el, win.getComputedStyle(el)) || el.getClientRects().length === 0)
224
+ throw fail("not_visible", "is not visible");
225
+ if (el.disabled === true || el.getAttribute("aria-disabled") === "true" || (el.closest && el.closest("fieldset[disabled]")))
226
+ throw fail("disabled", "is disabled");
227
+ el.scrollIntoView({ block: "center", inline: "center" });
228
+ const r = el.getBoundingClientRect();
229
+ const w = el.ownerDocument.defaultView;
230
+ if (r.right <= 0 || r.bottom <= 0 || r.left >= w.innerWidth || r.top >= w.innerHeight)
231
+ throw fail("not_visible", "is not visible");
232
+ const x = r.left + r.width / 2, y = r.top + r.height / 2;
233
+ const top = el.ownerDocument.elementFromPoint(x, y);
234
+ if (top && top !== el && !el.contains(top) && !top.contains(el))
235
+ throw fail("covered", "is covered by another element");
236
+ return { x, y, target: top && el.contains(top) ? top : el };
237
+ }
238
+ function tap(el, ref) {
239
+ const { x, y, target } = ready(el, ref);
240
+ const w = el.ownerDocument.defaultView;
241
+ const init = { bubbles: true, cancelable: true, composed: true, view: w, clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
242
+ const fire = (Ctor, type, buttons) => target.dispatchEvent(new Ctor(type, { ...init, buttons }));
243
+ fire(w.PointerEvent, "pointerdown", 1);
244
+ fire(w.MouseEvent, "mousedown", 1);
245
+ if (typeof el.focus === "function")
246
+ el.focus({ preventScroll: true });
247
+ fire(w.PointerEvent, "pointerup", 0);
248
+ fire(w.MouseEvent, "mouseup", 0);
249
+ fire(w.MouseEvent, "click", 0);
250
+ }
251
+ function type(el, ref, text, enter) {
252
+ const field = /^(INPUT|TEXTAREA)$/.test(el.tagName) ? el : el.querySelector("input, textarea");
253
+ if (!field)
254
+ throw new ActError("not_a_field", `${ref} is not a text field. Type into a "textbox" ref.`);
255
+ ready(field, ref);
256
+ const w = field.ownerDocument.defaultView;
257
+ field.focus();
258
+ const proto = field.tagName === "TEXTAREA" ? w.HTMLTextAreaElement.prototype : w.HTMLInputElement.prototype;
259
+ Object.getOwnPropertyDescriptor(proto, "value").set.call(field, text);
260
+ field.dispatchEvent(new w.InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
261
+ field.dispatchEvent(new w.Event("change", { bubbles: true }));
262
+ if (enter)
263
+ for (const t of ["keydown", "keypress", "keyup"])
264
+ field.dispatchEvent(new w.KeyboardEvent(t, { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true }));
265
+ return field.value;
266
+ }
267
+ function scroll(ref, direction) {
268
+ const d = appDoc();
269
+ const w = d.defaultView;
270
+ const dx = direction === "right" ? 300 : direction === "left" ? -300 : 0;
271
+ const dy = direction === "down" ? 600 : direction === "up" ? -600 : 0;
272
+ if (!dx && !dy)
273
+ throw new ActError("bad_direction", "scroll needs a direction: up, down, left or right.");
274
+ let el = ref ? resolve(ref) : d.body;
275
+ // The nearest ancestor that can scroll that way. Fall back to the page.
276
+ for (; el && el !== d.body && el !== d.documentElement; el = el.parentElement) {
277
+ const cs = w.getComputedStyle(el);
278
+ if (dy && /auto|scroll/.test(cs.overflowY) && el.scrollHeight > el.clientHeight)
279
+ break;
280
+ if (dx && /auto|scroll/.test(cs.overflowX) && el.scrollWidth > el.clientWidth)
281
+ break;
282
+ }
283
+ if (!el || el === d.body || el === d.documentElement)
284
+ el = d.scrollingElement || d.documentElement;
285
+ el.scrollBy({ left: dx, top: dy, behavior: "instant" });
286
+ }
287
+ function back() {
288
+ const d = appDoc();
289
+ for (const el of d.querySelectorAll("button, [role=button], input[type=button], input[type=submit]")) {
290
+ const role = roleOf(el);
291
+ if (role === "button" && !hidden(el, win.getComputedStyle(el)) && nameOf(el, role).name === "Back") {
292
+ tap(el, "Back");
293
+ return;
294
+ }
295
+ }
296
+ throw new Error("no Back button on this screen");
297
+ }
298
+ const sleep = (ms) => new Promise((r) => win.setTimeout(r, ms));
299
+ /** Give the app a moment to react, and wait for a page load a tap started. */
300
+ async function settle() {
301
+ await sleep(250);
302
+ for (let i = 0; i < 30; i++) {
303
+ const d = appDoc();
304
+ if (d && d.readyState === "complete" && d.body)
305
+ break;
306
+ await sleep(100);
307
+ }
308
+ await sleep(50);
309
+ }
310
+ const screenOrUndefined = () => { try {
311
+ return tree();
312
+ }
313
+ catch {
314
+ return undefined;
315
+ } };
316
+ async function act(m) {
317
+ switch (m.type) {
318
+ case "tap":
319
+ tap(resolve(m.ref), m.ref);
320
+ break;
321
+ case "type": {
322
+ const value = type(resolve(m.ref), m.ref, String(m.text ?? ""), !!m.enter);
323
+ await settle();
324
+ return { delivery: "delivered", value, screen: tree() };
325
+ }
326
+ case "scroll":
327
+ scroll(m.ref, m.direction);
328
+ break;
329
+ case "back":
330
+ back();
331
+ break;
332
+ default: throw new Error("unknown request: " + m.type);
333
+ }
334
+ await settle();
335
+ return { delivery: "delivered", screen: tree() };
336
+ }
337
+ const ws = new win.WebSocket((win.location.protocol === "https:" ? "wss://" : "ws://") + win.location.host + "/__shipvo/agent?role=screen&token=" + encodeURIComponent(token));
338
+ ws.onmessage = (e) => {
339
+ let m;
340
+ try {
341
+ m = JSON.parse(String(e.data));
342
+ }
343
+ catch {
344
+ return;
345
+ }
346
+ if (!m || typeof m.id !== "number")
347
+ return;
348
+ try {
349
+ if (m.type === "tree")
350
+ ws.send(JSON.stringify({ id: m.id, type: "tree", ok: true, tree: tree() }));
351
+ else if (/^(tap|type|scroll|back)$/.test(m.type)) {
352
+ act(m).then((r) => ws.send(JSON.stringify({ id: m.id, type: m.type, ok: true, ...r })), (err) => ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, ...(err && err.code ? { code: err.code } : {}), error: String(err && err.message || err), screen: screenOrUndefined() })));
353
+ }
354
+ else
355
+ ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: "unknown request: " + m.type }));
356
+ }
357
+ catch (err) {
358
+ ws.send(JSON.stringify({ id: m.id, type: m.type, ok: false, error: String(err && err.message || err) }));
359
+ }
360
+ };
361
+ }
362
+ /** The script text served at `/__shipvo/bridge.js`. It reads the session token from its own `data-token` attribute and then removes the attribute. */
363
+ export function bridgeScript(rules = screenRulesSource()) {
364
+ return `(function(){
365
+ var __rules=(function(){${rules}
366
+ return {clearScreenRules:clearScreenRules,applyScreenRules:applyScreenRules,filterTree:filterTree};})();
367
+ globalThis.__shipvoRules=__rules;
368
+ var tag=document.currentScript;
369
+ var tok=tag?tag.getAttribute("data-token")||"":"";
370
+ if(tag)tag.removeAttribute("data-token");
371
+ (${bridgeMain.toString()})(window, tok);
372
+ })();
373
+ `;
374
+ }
@@ -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,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>;