@shipvo/cli 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +29 -0
  4. package/dist/agents.js +183 -0
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +92 -0
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +11 -0
  20. package/dist/ci.js +100 -0
  21. package/dist/cli.js +51 -1
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.js +68 -13
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +88 -8
  26. package/dist/eas.d.ts +29 -0
  27. package/dist/eas.js +258 -0
  28. package/dist/ensure-playwright.d.ts +25 -0
  29. package/dist/ensure-playwright.js +69 -0
  30. package/dist/explore-issues.d.ts +12 -0
  31. package/dist/explore-issues.js +50 -0
  32. package/dist/explore.d.ts +160 -0
  33. package/dist/explore.js +496 -0
  34. package/dist/flows.d.ts +31 -0
  35. package/dist/flows.js +264 -0
  36. package/dist/help.d.ts +2 -2
  37. package/dist/help.js +37 -7
  38. package/dist/hermes.d.ts +10 -0
  39. package/dist/hermes.js +50 -0
  40. package/dist/init.d.ts +1 -0
  41. package/dist/init.js +71 -19
  42. package/dist/json-out.d.ts +53 -0
  43. package/dist/json-out.js +91 -0
  44. package/dist/keys-json.d.ts +1 -0
  45. package/dist/keys-json.js +71 -0
  46. package/dist/login.js +27 -11
  47. package/dist/lumi.d.ts +104 -0
  48. package/dist/lumi.js +364 -0
  49. package/dist/migrate.d.ts +27 -0
  50. package/dist/migrate.js +521 -0
  51. package/dist/policy.d.ts +2 -16
  52. package/dist/policy.js +2 -18
  53. package/dist/preview-recorder.d.ts +1 -0
  54. package/dist/preview-recorder.js +62 -0
  55. package/dist/preview.d.ts +4 -3
  56. package/dist/preview.js +114 -15
  57. package/dist/promote.d.ts +1 -1
  58. package/dist/promote.js +63 -14
  59. package/dist/publish.d.ts +14 -2
  60. package/dist/publish.js +131 -56
  61. package/dist/qr.d.ts +9 -0
  62. package/dist/qr.js +213 -0
  63. package/dist/report-md.d.ts +27 -0
  64. package/dist/report-md.js +58 -0
  65. package/dist/rotation.d.ts +57 -0
  66. package/dist/rotation.js +157 -0
  67. package/dist/run-bridge.d.ts +7 -0
  68. package/dist/run-bridge.js +728 -0
  69. package/dist/run-net.d.ts +39 -0
  70. package/dist/run-net.js +266 -0
  71. package/dist/run-protocol.d.ts +32 -0
  72. package/dist/run-protocol.js +22 -0
  73. package/dist/run-ws.d.ts +8 -0
  74. package/dist/run-ws.js +89 -0
  75. package/dist/run.d.ts +109 -0
  76. package/dist/run.js +385 -0
  77. package/dist/signing.js +13 -0
  78. package/dist/sim.d.ts +4 -0
  79. package/dist/sim.js +25 -7
  80. package/dist/smoke.d.ts +28 -0
  81. package/dist/smoke.js +117 -0
  82. package/dist/spawn-bin.d.ts +11 -0
  83. package/dist/spawn-bin.js +21 -0
  84. package/dist/status.d.ts +71 -1
  85. package/dist/status.js +162 -5
  86. package/dist/store.d.ts +36 -0
  87. package/dist/store.js +266 -3
  88. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  89. package/dist/templates/github-action/shipvo-check.yml +18 -0
  90. package/dist/test.d.ts +46 -0
  91. package/dist/test.js +253 -0
  92. package/package.json +7 -4
  93. package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/lumi.js ADDED
@@ -0,0 +1,364 @@
1
+ // `shipvo lumi`: the app on Shipvo Phone in your browser, with a sidebar for Lumi (live feed, findings, coverage, command box).
2
+ // Commands are plain text matched by rules. There is no AI call. `Lumi` is the engine and only needs a `Driver`, so tests run it
3
+ // against a fake app. `lumi()` wires it to the simulator and a small local web server.
4
+ import { createServer } from "node:http";
5
+ import { args as parse, loadConfig } from "./config.js";
6
+ import { elements, fieldElements, signature, describeStep, walk, startSim, simDriver } from "./explore.js";
7
+ import { portFree } from "./sim.js";
8
+ import { loadFrameRenderer, openBrowser } from "./run.js";
9
+ /** Reads a line of text as a command. No AI: a few patterns, case and spacing ignored. */
10
+ export function parseCommand(text) {
11
+ const t = text.trim().replace(/\s+/g, " ").replace(/[.!]+$/, "");
12
+ let m;
13
+ if (/^(help|\?|commands)$/i.test(t))
14
+ return { kind: "help" };
15
+ if (/^(stop|cancel|halt)$/i.test(t))
16
+ return { kind: "stop" };
17
+ if ((m = /^replay (?:bug|finding) #?(\d+)$/i.exec(t)))
18
+ return { kind: "replay", n: Number(m[1]) };
19
+ if (/^test (?:my |the )?(?:whole )?app$/i.test(t) || /^test$/i.test(t))
20
+ return { kind: "test-all" };
21
+ if ((m = /^try (.+?) with bad (?:input|data|values)$/i.exec(t)))
22
+ return { kind: "bad-input", flow: m[1].replace(/^(?:the|my) /i, "").replace(/ flow$/i, "") };
23
+ if ((m = /^test (.+)$/i.exec(t)))
24
+ return { kind: "test-flow", flow: m[1].replace(/^(?:the|my) /i, "").replace(/ flow$/i, "") };
25
+ return { kind: "unknown" };
26
+ }
27
+ export const COMMAND_LIST = ["test my app", "test <flow>", "try <flow> with bad input", "stop", "replay bug <n>", "help"];
28
+ export class Lumi {
29
+ driver;
30
+ o;
31
+ feed = [];
32
+ known = [];
33
+ screens = new Set();
34
+ seen = new Set();
35
+ tried = new Set();
36
+ mood = "idle";
37
+ running = false;
38
+ label = "";
39
+ stopFlag = false;
40
+ listeners = new Set();
41
+ job = Promise.resolve();
42
+ constructor(driver, o = {}) {
43
+ this.driver = driver;
44
+ this.o = o;
45
+ }
46
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
47
+ state() {
48
+ return {
49
+ mood: this.mood, running: this.running, label: this.label, feed: this.feed.slice(-200),
50
+ findings: this.known.map((k) => ({ n: k.n, kind: k.finding.kind, message: k.finding.message, steps: k.finding.steps.map(describeStep) })),
51
+ coverage: { screens: this.screens.size, controlsSeen: this.seen.size, controlsTried: this.tried.size },
52
+ };
53
+ }
54
+ /** Resolves when the current run or replay has ended. */
55
+ idle() { return this.job; }
56
+ emit() { const s = this.state(); for (const f of this.listeners)
57
+ f(s); }
58
+ say(text, kind = "info") { this.feed.push({ at: new Date().toISOString(), text, kind }); if (this.feed.length > 500)
59
+ this.feed.splice(0, this.feed.length - 500); this.emit(); }
60
+ /** Names you can use after `test` and `try`: configured routes, then the controls on the start screen. */
61
+ async flows() {
62
+ const names = (this.o.routes ?? []).map(routeName).filter(Boolean);
63
+ try {
64
+ const r = await this.driver.open();
65
+ if (r.ok && r.screen)
66
+ for (const e of elements(r.screen))
67
+ if (e.name)
68
+ names.push(e.name);
69
+ }
70
+ catch { /* the list is only a hint */ }
71
+ return [...new Set(names)];
72
+ }
73
+ async resolve(flow) {
74
+ const f = flow.toLowerCase();
75
+ const routes = (this.o.routes ?? []).filter((r) => routeName(r));
76
+ const hit = routes.find((r) => routeName(r) === f) ?? routes.find((r) => routeName(r).includes(f));
77
+ if (hit)
78
+ return { label: routeName(hit), paths: [[{ action: "open" }, { action: "open_url", url: hit }]] };
79
+ const r = await this.driver.open();
80
+ const els = r.ok && r.screen ? elements(r.screen).filter((e) => e.name) : [];
81
+ const c = els.find((e) => e.name.toLowerCase() === f) ?? els.find((e) => e.name.toLowerCase().includes(f));
82
+ if (c)
83
+ return { label: c.name, paths: [[{ action: "open" }, { action: "tap", role: c.role, name: c.name, nth: c.nth }]] };
84
+ const known = await this.flows();
85
+ return { error: `I do not know a flow called "${flow}".${known.length ? ` Try one of: ${known.join(", ")}.` : ""}` };
86
+ }
87
+ /** Handles one line from the command box. Returns the short reply at once; a run goes on in the background (see `idle`). */
88
+ async command(text) {
89
+ const c = parseCommand(text);
90
+ if (c.kind === "help")
91
+ return `Commands: ${COMMAND_LIST.join("; ")}.`;
92
+ if (c.kind === "unknown")
93
+ return `I do not know that one. Commands: ${COMMAND_LIST.join("; ")}.`;
94
+ if (c.kind === "stop") {
95
+ if (!this.running)
96
+ return "Nothing is running.";
97
+ this.stopFlag = true;
98
+ this.say("Stopping after this action.");
99
+ return "Stopping after this action.";
100
+ }
101
+ if (this.running)
102
+ return "I am busy. Say stop first, or wait for the run to end.";
103
+ if (c.kind === "replay") {
104
+ const k = this.known[c.n - 1];
105
+ if (!k)
106
+ return this.known.length ? `There is no bug ${c.n}. Bugs so far: 1 to ${this.known.length}.` : "There are no bugs to replay yet. Say \"test my app\" first.";
107
+ this.start(`replay bug ${c.n}`, () => this.replay(k));
108
+ return `Replaying bug ${c.n}.`;
109
+ }
110
+ if (c.kind === "test-all") {
111
+ this.start("test my app", () => this.run({ routes: this.o.routes }));
112
+ return "Testing the whole app.";
113
+ }
114
+ const r = await this.resolve(c.flow);
115
+ if ("error" in r)
116
+ return r.error;
117
+ const bad = c.kind === "bad-input";
118
+ this.start(bad ? `${r.label} with bad input` : r.label, () => this.run({ startPaths: r.paths, ...(bad ? { input: "bad" } : {}) }));
119
+ return bad ? `Trying ${r.label} with bad input.` : `Testing ${r.label}.`;
120
+ }
121
+ start(label, fn) {
122
+ this.running = true;
123
+ this.stopFlag = false;
124
+ this.label = label;
125
+ this.mood = "working";
126
+ this.say(`Starting: ${label}.`);
127
+ this.job = fn().catch((e) => { this.say(`Stopped by an error: ${e instanceof Error ? e.message : String(e)}`, "bug"); this.mood = "worried"; })
128
+ .finally(() => { this.running = false; this.label = ""; this.emit(); });
129
+ }
130
+ async run(w) {
131
+ const before = this.known.length;
132
+ const r = await walk(this.driver, {
133
+ maxDepth: this.o.maxDepth, maxSteps: this.o.maxSteps, seed: this.o.seed, skin: this.o.skin, app: this.o.app, settleMs: this.o.settleMs, ...w,
134
+ shouldStop: () => this.stopFlag,
135
+ onEvent: (e) => {
136
+ if (e.type === "step") {
137
+ if (e.step.action === "tap" || e.step.action === "type")
138
+ this.tried.add(`${e.step.role}|${e.step.name}`);
139
+ this.say(e.text, "step");
140
+ }
141
+ else if (e.type === "screen") {
142
+ this.screens.add(e.sig);
143
+ for (const c of e.controls)
144
+ this.seen.add(`${c.role}|${c.name}`);
145
+ this.emit();
146
+ }
147
+ else {
148
+ const key = `${e.finding.kind}|${e.finding.message}|${signature(e.finding.screen)}`;
149
+ if (this.known.some((k) => k.key === key))
150
+ return;
151
+ const n = this.known.push({ n: this.known.length + 1, key, finding: e.finding });
152
+ this.say(`Bug ${n}: ${e.finding.message}`, "bug");
153
+ }
154
+ },
155
+ });
156
+ const found = this.known.length - before;
157
+ this.mood = this.known.length ? "worried" : r.error || r.stoppedEarly ? "idle" : "happy";
158
+ const why = r.stoppedEarly ? ` Stopped: ${r.stoppedEarly}.` : "";
159
+ this.say(`Done. ${r.steps} action${r.steps === 1 ? "" : "s"}, ${r.screens} screen${r.screens === 1 ? "" : "s"}, ${found} new bug${found === 1 ? "" : "s"}.${why}${r.error ? ` Error: ${r.error}` : ""}`);
160
+ }
161
+ /** Does the steps of a bug again, one by one, and says whether the screen is the same as when it was found. */
162
+ async replay(k) {
163
+ let screen = "";
164
+ let ok = true;
165
+ await this.driver.errors().catch(() => []);
166
+ for (const s of k.finding.steps) {
167
+ if (this.stopFlag) {
168
+ this.mood = this.known.length ? "worried" : "idle";
169
+ this.say("Replay stopped.");
170
+ return;
171
+ }
172
+ this.say(describeStep(s), "step");
173
+ let r;
174
+ if (s.action === "open")
175
+ r = await this.driver.open();
176
+ else if (s.action === "open_url")
177
+ r = await this.driver.openUrl(s.url);
178
+ else if (s.action === "back")
179
+ r = await this.driver.back();
180
+ else {
181
+ const el = (s.action === "type" ? fieldElements(screen) : elements(screen)).find((e) => e.role === s.role && e.name === s.name && e.nth === s.nth);
182
+ if (!el) {
183
+ this.say(`Could not find ${s.role} "${s.name}" on this screen. The app may have changed since bug ${k.n}.`, "bug");
184
+ ok = false;
185
+ break;
186
+ }
187
+ r = s.action === "type" ? await this.driver.type?.(el.ref, s.text, s.enter) ?? { ok: false, error: "this simulator cannot type" } : await this.driver.tap(el.ref);
188
+ }
189
+ if (r.screen != null)
190
+ screen = r.screen;
191
+ if (!r.ok && s.action !== "back") {
192
+ this.say(`Action failed: ${r.error ?? "unknown"}`, "bug");
193
+ ok = false;
194
+ break;
195
+ }
196
+ if (this.o.walkDelayMs)
197
+ await new Promise((res) => setTimeout(res, this.o.walkDelayMs));
198
+ }
199
+ const errs = await this.driver.errors().catch(() => []);
200
+ const same = ok && signature(screen) === signature(k.finding.screen);
201
+ this.mood = same || errs.length ? "worried" : "happy";
202
+ this.say(same ? `Bug ${k.n} is still there: the screen is the same as when I found it.${errs.length ? ` Errors: ${errs.join("; ")}` : ""}` : errs.length ? `Bug ${k.n}: the steps raised ${errs.length} error${errs.length === 1 ? "" : "s"}: ${errs.join("; ")}` : ok ? `Bug ${k.n} did not show up this time. The screen is different.` : `Could not replay bug ${k.n} to the end.`, same || errs.length ? "bug" : "info");
203
+ }
204
+ }
205
+ /** "tide://cart" -> "cart", "/orders/open" -> "open". */
206
+ export function routeName(url) {
207
+ return (url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[/?#]/).filter(Boolean).pop() ?? "").toLowerCase();
208
+ }
209
+ // ---- the page ----
210
+ const SIDEBAR = `<aside class="lm" aria-label="Lumi" data-part="sidebar">
211
+ <h1><span data-part="mood-label">Lumi</span></h1><p class="lm-now" data-part="now" role="status" aria-live="polite">Idle. Say "test my app".</p>
212
+ <section aria-labelledby="lm-h-feed"><h2 id="lm-h-feed">What Lumi is doing</h2><ol class="lm-feed" data-part="feed" aria-live="off"></ol></section>
213
+ <section aria-labelledby="lm-h-bugs"><h2 id="lm-h-bugs">Findings <span data-part="bug-count">0</span></h2><ol class="lm-bugs" data-part="findings"></ol></section>
214
+ <section aria-labelledby="lm-h-cov"><h2 id="lm-h-cov">Coverage</h2><dl class="lm-cov" data-part="coverage"><dt>Screens</dt><dd data-part="cov-screens">0</dd><dt>Controls tried</dt><dd data-part="cov-tried">0 of 0</dd></dl></section>
215
+ <form class="lm-cmd" data-part="command-form"><label for="lm-in">Tell Lumi what to do</label><div><input id="lm-in" data-part="command" autocomplete="off" placeholder="test my app" spellcheck="false"><button type="submit">Send</button></div><p class="lm-reply" data-part="reply" role="status"></p></form>
216
+ </aside>`;
217
+ const CSS = `:root{color-scheme:light dark;--bg:#eef1f4;--fg:#111820;--card:#fff;--line:#d3d9e0;--muted:#55606c;--accent:#1769aa;--bad:#b3261e}
218
+ @media (prefers-color-scheme:dark){:root{--bg:#10151c;--fg:#eaf0f6;--card:#192029;--line:#2c3743;--muted:#9fb0c0;--accent:#7fc4f5;--bad:#ff9a8a}}
219
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
220
+ .lm-page{display:flex;height:100%;gap:16px;padding:16px;box-sizing:border-box}.lm-stage{flex:1;min-width:0;display:flex;align-items:center;justify-content:center;overflow:hidden}
221
+ .lm-stage .sf-content img{display:block;width:100%;height:100%;object-fit:contain;background:#fff}
222
+ .lm{flex:none;width:360px;display:flex;flex-direction:column;gap:10px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;box-sizing:border-box;overflow:auto}
223
+ .lm h1{margin:0;font-size:18px}.lm h2{margin:0 0 4px;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.lm p{margin:0}.lm-now{color:var(--muted)}
224
+ .lm ol{margin:0;padding:0;list-style:none}.lm-feed{max-height:190px;overflow:auto;font-family:ui-monospace,monospace;font-size:12px}.lm-feed li{padding:2px 0;border-bottom:1px solid var(--line)}.lm-feed .bug,.lm-bugs{color:var(--bad)}
225
+ .lm-bugs{max-height:170px;overflow:auto}.lm-bugs li{display:flex;gap:6px;align-items:flex-start;padding:3px 0}.lm-bugs span{flex:1}.lm-bugs button{flex:none;font:inherit;font-size:12px;padding:2px 8px}
226
+ .lm-cov{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;margin:0}.lm-cov dt{color:var(--muted)}.lm-cov dd{margin:0;font-weight:600}
227
+ .lm-cmd label{display:block;margin-bottom:4px;font-weight:600}.lm-cmd div{display:flex;gap:6px}.lm-cmd input{flex:1;min-width:0;font:inherit;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg)}
228
+ .lm-cmd button{font:inherit;padding:8px 14px;border:0;border-radius:8px;background:var(--accent);color:var(--bg);font-weight:600;cursor:pointer}.lm-reply{margin-top:6px;color:var(--muted);min-height:1.2em}
229
+ .lm button:focus-visible,.lm input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
230
+ @media (max-width:900px){.lm-page{flex-direction:column;overflow:auto}.lm{width:auto}.lm-stage{flex:none;height:50vh}}`;
231
+ const CLIENT = `(function(){var $=function(p){return document.querySelector('[data-part="'+p+'"]')};
232
+ var phone=document.querySelector('[data-part="phone-body"]'),stage=document.querySelector(".lm-stage");
233
+ function fit(){var k=Math.min(1,(stage.clientWidth-8)/phone.offsetWidth,(stage.clientHeight-8)/phone.offsetHeight);phone.style.transform="scale("+k+")";phone.style.transformOrigin="center"}
234
+ addEventListener("resize",fit);if(window.ResizeObserver)new ResizeObserver(fit).observe(stage);fit();
235
+ var LABEL={idle:"Lumi",working:"Lumi is testing",happy:"Lumi is happy",worried:"Lumi found something"};
236
+ function li(t,c){var e=document.createElement("li");if(c)e.className=c;e.textContent=t;return e}
237
+ function render(s){
238
+ if(window.shipvoLumi)window.shipvoLumi.set(s.mood);
239
+ $("mood-label").textContent=LABEL[s.mood]||"Lumi";
240
+ var last=s.feed[s.feed.length-1];$("now").textContent=s.running?(s.label?"Working on: "+s.label+". ":"")+(last?last.text:""):(last?last.text:'Idle. Say "test my app".');
241
+ var f=$("feed");f.textContent="";s.feed.slice(-40).forEach(function(i){f.append(li(i.text,i.kind==="bug"?"bug":""))});f.scrollTop=f.scrollHeight;
242
+ var b=$("findings");b.textContent="";$("bug-count").textContent=s.findings.length;
243
+ s.findings.forEach(function(x){var e=document.createElement("li"),t=document.createElement("span");t.textContent="Bug "+x.n+": "+x.message;
244
+ var r=document.createElement("button");r.type="button";r.textContent="Replay";r.setAttribute("aria-label","Replay bug "+x.n);r.onclick=function(){send("replay bug "+x.n)};e.append(t,r);b.append(e)});
245
+ $("cov-screens").textContent=s.coverage.screens;$("cov-tried").textContent=s.coverage.controlsTried+" of "+s.coverage.controlsSeen}
246
+ function send(t){fetch("/cmd",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({text:t})}).then(function(r){return r.json()}).then(function(j){$("reply").textContent=j.reply||j.error||""}).catch(function(){$("reply").textContent="Could not reach Lumi. Is shipvo lumi still running?"})}
247
+ $("command-form").onsubmit=function(e){e.preventDefault();var i=$("command"),v=i.value.trim();if(!v)return;i.value="";send(v)};
248
+ var state={running:false},es=new EventSource("/events");es.onmessage=function(e){state=JSON.parse(e.data);render(state)};
249
+ var img=document.querySelector('[data-part="screen"]');
250
+ function shot(){var n=new Image();n.onload=function(){img.src=n.src;next()};n.onerror=next;n.src="/shot?"+Date.now()}
251
+ function next(){setTimeout(shot,document.hidden?3000:state.running?700:2000)}
252
+ shot();
253
+ })();`;
254
+ /** The whole page: the phone frame with the live app picture in it, and the sidebar. */
255
+ export function lumiPage(frame, title) {
256
+ const safe = title.replace(/[<>&"']/g, "");
257
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lumi: ${safe}</title><style>${frame.css}\n${CSS}</style></head>`
258
+ + `<body><div class="lm-page"><main class="lm-stage">${frame.html}</main>${SIDEBAR}</div><script>${CLIENT.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
259
+ }
260
+ /** Local server for the page. It only answers requests whose Host is this machine's address, and only commands from this page. */
261
+ export function startLumiServer(o) {
262
+ const hosts = new Set([`localhost:${o.port}`, `127.0.0.1:${o.port}`, `[::1]:${o.port}`]);
263
+ const body = async (req) => { let b = ""; for await (const c of req) {
264
+ b += c;
265
+ if (b.length > 10_000)
266
+ throw new Error("too big");
267
+ } return b; };
268
+ const server = createServer(async (req, res) => {
269
+ const send = (code, data, type = "application/json") => { res.writeHead(code, { "content-type": type, "cache-control": "no-store", "x-content-type-options": "nosniff" }); res.end(data); };
270
+ try {
271
+ if (!hosts.has(String(req.headers.host)))
272
+ return send(403, '{"error":"forbidden"}');
273
+ const path = new URL(req.url ?? "/", "http://x").pathname;
274
+ if (req.method === "GET" && path === "/")
275
+ return send(200, o.page, "text/html; charset=utf-8");
276
+ if (req.method === "GET" && path === "/state")
277
+ return send(200, JSON.stringify(o.lumi.state()));
278
+ if (req.method === "GET" && path === "/shot") {
279
+ const b = await o.shot().catch(() => null);
280
+ return b ? send(200, b, "image/jpeg") : send(204, "");
281
+ }
282
+ if (req.method === "GET" && path === "/events") {
283
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
284
+ let off = () => { };
285
+ const push = (s) => { try {
286
+ res.write(`data: ${JSON.stringify(s)}\n\n`);
287
+ }
288
+ catch {
289
+ off();
290
+ } };
291
+ push(o.lumi.state());
292
+ off = o.lumi.subscribe(push);
293
+ req.on("close", off);
294
+ res.on("close", off);
295
+ return;
296
+ }
297
+ if (req.method === "POST" && path === "/cmd") {
298
+ const origin = req.headers.origin;
299
+ let oh = null;
300
+ try {
301
+ oh = origin ? new URL(origin).host : null;
302
+ }
303
+ catch {
304
+ oh = "bad";
305
+ }
306
+ if ((oh && oh !== req.headers.host) || !/^application\/json/i.test(String(req.headers["content-type"])))
307
+ return send(403, '{"error":"forbidden"}');
308
+ const text = String(JSON.parse(await body(req)).text ?? "");
309
+ return send(200, JSON.stringify({ reply: await o.lumi.command(text) }));
310
+ }
311
+ send(404, '{"error":"not found"}');
312
+ }
313
+ catch (e) {
314
+ send(500, JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
315
+ }
316
+ });
317
+ return new Promise((resolve, reject) => { server.once("error", reject); server.listen(o.port, "127.0.0.1", () => resolve(server)); });
318
+ }
319
+ const USAGE = "Usage: shipvo lumi [android|ios] [--port 4546] [--ui-port 4547] [--sim <url>] [--skip-build] [--no-open] [--max-steps 100] [--max-depth 3, at least 1] [--seed 1]";
320
+ /** Builds the app, opens it on Shipvo Phone in the browser with the Lumi sidebar. Runs until Ctrl-C. */
321
+ export async function lumi(argv) {
322
+ if (argv.includes("--help") || argv.includes("-h")) {
323
+ console.log(USAGE);
324
+ return 0;
325
+ }
326
+ const a = parse(argv);
327
+ const cfg = loadConfig();
328
+ const skin = (argv.includes("ios") ? "ios" : "android");
329
+ const num = (k, d) => { if (a[k] === undefined)
330
+ return d; const n = Number(a[k]); if (!Number.isInteger(n) || n < 0)
331
+ throw new Error(`--${k} must be a whole number`); return n; };
332
+ const uiPort = num("ui-port", 4547);
333
+ if (!(await portFree(uiPort)))
334
+ throw new Error(`Port ${uiPort} is in use. Pass --ui-port <n>.`);
335
+ const sim = await startSim(a, cfg);
336
+ let server;
337
+ try {
338
+ const driver = simDriver(sim.base, cfg.appId, "lumi", skin);
339
+ const engine = new Lumi(driver, { routes: cfg.routes, maxDepth: num("max-depth", 3) || 3, maxSteps: num("max-steps", 100) || 100, seed: a.seed !== undefined ? num("seed", 1) : undefined, skin, app: cfg.appId });
340
+ const first = await driver.open();
341
+ if (!first.ok)
342
+ throw new Error(`the app did not open: ${first.error ?? "no screen"}`);
343
+ const frame = (await loadFrameRenderer())({ skin, lumi: true, screen: `<img data-part="screen" alt="The app on the phone, live" draggable="false">` });
344
+ const shot = async () => {
345
+ const r = await fetch(`${sim.base}/act`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session: "lumi", action: "screenshot", format: "jpeg", inline: true }), signal: AbortSignal.timeout(10000) });
346
+ if (!r.ok)
347
+ return null;
348
+ const j = (await r.json());
349
+ return j.ok && j.data ? Buffer.from(j.data, "base64") : null;
350
+ };
351
+ server = await startLumiServer({ lumi: engine, page: lumiPage(frame, cfg.name), shot, port: uiPort });
352
+ const url = `http://localhost:${uiPort}/`;
353
+ console.error(`Lumi is on ${url} (Ctrl-C to stop). Say "help" in the sidebar for commands.`);
354
+ if (a["no-open"] !== "true")
355
+ openBrowser(url);
356
+ await new Promise((resolve) => { process.once("SIGINT", resolve); process.once("SIGTERM", resolve); });
357
+ return 0;
358
+ }
359
+ finally {
360
+ server?.close();
361
+ server?.closeAllConnections?.();
362
+ sim.stop();
363
+ }
364
+ }
@@ -0,0 +1,27 @@
1
+ export type Found = {
2
+ kind: "dependency" | "plugin" | "config-key" | "package" | "wrapper" | "sync" | "deployment-key" | "public-key" | "command";
3
+ where: string;
4
+ entry: string;
5
+ file?: string;
6
+ line?: number;
7
+ };
8
+ export type MigratePlan = {
9
+ system: "expo-updates" | "codepush" | null;
10
+ found: Found[];
11
+ steps: string[];
12
+ };
13
+ /** Reads the app and builds the plan. Changes nothing. */
14
+ export declare function buildPlan(dir: string): MigratePlan;
15
+ /** The safe edits: expo-updates dependency and plugin, and updates/runtimeVersion keys in app.json. */
16
+ export declare function applyEdits(dir: string): {
17
+ file: string;
18
+ removed: string[];
19
+ }[];
20
+ type CpEdit = {
21
+ file: string;
22
+ removed: string[];
23
+ };
24
+ /** CodePush --apply: wrapper and sync calls, native keys, and the dependency. Never runs a package manager. */
25
+ export declare function applyCodePushEdits(dir: string, dry?: boolean): CpEdit[];
26
+ export declare function migrate(argv: string[], dir?: string): Promise<number>;
27
+ export {};