@shipvo/cli 0.1.5 → 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 (77) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +15 -0
  4. package/dist/agents.js +68 -9
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +28 -11
  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 +8 -0
  20. package/dist/ci.js +64 -6
  21. package/dist/cli.js +23 -1
  22. package/dist/config.d.ts +13 -0
  23. package/dist/config.js +43 -3
  24. package/dist/doctor.js +63 -7
  25. package/dist/ensure-playwright.d.ts +4 -0
  26. package/dist/ensure-playwright.js +7 -2
  27. package/dist/explore-issues.d.ts +12 -0
  28. package/dist/explore-issues.js +50 -0
  29. package/dist/explore.d.ts +83 -7
  30. package/dist/explore.js +326 -77
  31. package/dist/flows.d.ts +31 -0
  32. package/dist/flows.js +264 -0
  33. package/dist/help.d.ts +2 -2
  34. package/dist/help.js +19 -10
  35. package/dist/hermes.d.ts +10 -0
  36. package/dist/hermes.js +50 -0
  37. package/dist/init.d.ts +1 -0
  38. package/dist/init.js +41 -6
  39. package/dist/json-out.d.ts +53 -0
  40. package/dist/json-out.js +91 -0
  41. package/dist/keys-json.d.ts +1 -0
  42. package/dist/keys-json.js +71 -0
  43. package/dist/login.js +27 -11
  44. package/dist/lumi.d.ts +104 -0
  45. package/dist/lumi.js +364 -0
  46. package/dist/migrate.d.ts +27 -0
  47. package/dist/migrate.js +521 -0
  48. package/dist/preview.d.ts +1 -0
  49. package/dist/preview.js +4 -2
  50. package/dist/promote.d.ts +1 -1
  51. package/dist/promote.js +38 -16
  52. package/dist/publish.d.ts +14 -2
  53. package/dist/publish.js +126 -62
  54. package/dist/qr.d.ts +9 -0
  55. package/dist/qr.js +213 -0
  56. package/dist/report-md.d.ts +1 -0
  57. package/dist/report-md.js +1 -1
  58. package/dist/rotation.d.ts +57 -0
  59. package/dist/rotation.js +157 -0
  60. package/dist/run-bridge.js +356 -2
  61. package/dist/run-net.d.ts +39 -0
  62. package/dist/run-net.js +266 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +101 -17
  65. package/dist/signing.js +13 -0
  66. package/dist/sim.js +7 -3
  67. package/dist/smoke.d.ts +28 -0
  68. package/dist/smoke.js +117 -0
  69. package/dist/spawn-bin.d.ts +11 -0
  70. package/dist/spawn-bin.js +21 -0
  71. package/dist/status.d.ts +26 -4
  72. package/dist/status.js +69 -5
  73. package/dist/templates/github-action/shipvo-check.yml +18 -0
  74. package/dist/test.d.ts +1 -1
  75. package/dist/test.js +81 -18
  76. package/package.json +4 -3
  77. package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/flows.js ADDED
@@ -0,0 +1,264 @@
1
+ // Lumi flows: recognise sign up, log in, search, add to cart and checkout by control labels and screen text,
2
+ // drive each to its end with valid input, then once with invalid input. No AI model. Works on any `Driver`.
3
+ import { elements, signature, sampleValue, describeStep } from "./explore.js";
4
+ export const FLOW_NAMES = ["signup", "login", "search", "add_to_cart", "checkout"];
5
+ const FIELD_ROLES = new Set(["textbox", "searchbox"]);
6
+ const RISKY = /\b(log ?out|sign ?out|delete|remove|cancel|erase|deactivate|clear all|unsubscribe)\b/i;
7
+ const RED = /Uncaught (\w*Error)|Unhandled Runtime Error|Application error|Something went wrong/i;
8
+ const NET = /network request failed|network error|failed to fetch|no internet|(?:can't|cannot|couldn't|unable to) connect|connection (?:failed|lost|error)/i;
9
+ const REJECTED = /invalid|incorrect|wrong|required|must be|not valid|too short|try again|\berror\b|can't be empty|enter a/i;
10
+ const ORDER_DONE = /\border\b[^\n]{0,40}\b(placed|confirmed|complete|successful)|payment (?:approved|successful|complete)|thank you|receipt/i;
11
+ const ADDED = /\badded\b|in (?:your )?(?:cart|bag|basket)/i;
12
+ const ADD_BTN = /^add\b.*\b(cart|bag|basket)\b|^add (?:\d+ )?to (?:cart|bag|basket)/i;
13
+ const CART_NAV = /\b(cart|bag|basket|checkout)\b/i;
14
+ /** Controls that look like a product: a price in the label, or the word product. */
15
+ const PRODUCT = /\d[\d\s.,]*\s?(?:FCFA|XAF|USD|EUR|GBP|[$€£])|[$€£]\s?\d/i;
16
+ const NAV_ONLY = /^(back|go back|close|home|menu|settings|about)\b/i;
17
+ class Fail extends Error {
18
+ }
19
+ /** Fields (text boxes) in a tree, with the same nth numbering the walker uses. */
20
+ function fieldsOf(tree) {
21
+ const out = [];
22
+ const seen = new Map();
23
+ for (const l of tree.split("\n")) {
24
+ const m = /^\s*- (textbox|searchbox)(?: "((?:[^"\\]|\\.)*)")?[^\n]*?\[ref=(\w+)\]/.exec(l);
25
+ if (!m || /\[disabled\]/.test(l))
26
+ continue;
27
+ const name = (m[2] ?? "").trim();
28
+ const key = `${m[1]}|${name}`;
29
+ const nth = seen.get(key) ?? 0;
30
+ seen.set(key, nth + 1);
31
+ out.push({ role: m[1], name, ref: m[3], nth });
32
+ }
33
+ return out;
34
+ }
35
+ class Session {
36
+ d;
37
+ steps = [];
38
+ screen = "";
39
+ constructor(d) {
40
+ this.d = d;
41
+ }
42
+ async settle(r, step) {
43
+ this.steps.push(step);
44
+ if (!r.ok || r.screen == null)
45
+ throw new Fail(`${describeStep(step)} failed: ${r.error ?? "no screen"}`);
46
+ this.screen = r.screen;
47
+ for (const e of await this.d.errors())
48
+ throw new Fail(`${describeStep(step)} raised an error: ${e}`);
49
+ const bad = RED.exec(r.screen)?.[0] ?? NET.exec(r.screen)?.[0];
50
+ if (bad)
51
+ throw new Fail(`${describeStep(step)} led to a screen that shows "${bad}"`);
52
+ }
53
+ async open() { this.steps = []; await this.settle(await this.d.open(), { action: "open" }); }
54
+ async tap(e) { await this.settle(await this.d.tap(e.ref), { action: "tap", role: e.role, name: e.name, nth: e.nth }); }
55
+ async type(e, text, enter = false) {
56
+ if (!this.d.type)
57
+ throw new Fail("this simulator cannot type");
58
+ await this.settle(await this.d.type(e.ref, text, enter), { action: "type", role: e.role, name: e.name, nth: e.nth, text, ...(enter ? { enter: true } : {}) });
59
+ }
60
+ async back() { const r = await this.d.back(); if (r.ok && r.screen != null) {
61
+ this.screen = r.screen;
62
+ this.steps.push({ action: "back" });
63
+ return true;
64
+ } return false; }
65
+ buttons(re) { return elements(this.screen).filter((e) => e.name && !RISKY.test(e.name) && (!re || re.test(e.name))); }
66
+ /** Type into every field on the screen, in order. Fields are found again after each one, since refs can change. */
67
+ async fill(invalid, only) {
68
+ const want = fieldsOf(this.screen).filter((f) => !only || only.test(f.name));
69
+ for (const f of want) {
70
+ const cur = fieldsOf(this.screen).find((x) => x.role === f.role && x.name === f.name && x.nth === f.nth);
71
+ if (cur)
72
+ await this.type(cur, sampleValue(f.name, invalid), invalid && fieldsOf(this.screen).length === 1);
73
+ }
74
+ return want.length;
75
+ }
76
+ }
77
+ /** Look for something on this screen, then one tap away through navigation controls. If not found, step back (twice at most) and look again. */
78
+ async function locate(s, test, prefer) {
79
+ for (let up = 0; up < 3; up++) {
80
+ const here = test(s.screen);
81
+ if (here)
82
+ return here;
83
+ const start = signature(s.screen);
84
+ const hops = s.buttons().filter((e) => !NAV_ONLY.test(e.name) && !FIELD_ROLES.has(e.role) && !ADD_BTN.test(e.name));
85
+ hops.sort((a, b) => Number(!!prefer?.test(b.name)) - Number(!!prefer?.test(a.name)));
86
+ for (const h of hops.slice(0, 8)) {
87
+ await s.tap(h);
88
+ const found = test(s.screen);
89
+ if (found)
90
+ return found;
91
+ if (!(await s.back()) || signature(s.screen) !== start)
92
+ return null;
93
+ }
94
+ if (!(await s.back()))
95
+ return null;
96
+ }
97
+ return null;
98
+ }
99
+ const submitBtn = (s, re) => s.buttons(re).find((b) => !FIELD_ROLES.has(b.role));
100
+ const stillForm = (tree, needPass) => fieldsOf(tree).some((f) => (needPass ? /pass/i : /./).test(f.name));
101
+ async function authFlow(s, kind, invalid) {
102
+ const entry = kind === "signup" ? /sign ?up|register|create (?:an )?account|join/i : /log ?in|sign ?in/i;
103
+ const submit = kind === "signup" ? /sign ?up|register|create|continue|submit|next|join/i : /log ?in|sign ?in|continue|submit/i;
104
+ const hasForm = (t) => (fieldsOf(t).some((f) => /pass/i.test(f.name)) || fieldsOf(t).some((f) => /e-?mail|user/i.test(f.name))) && !!elements(t).some((e) => submit.test(e.name)) ? t : null;
105
+ await s.open();
106
+ let form = await locate(s, hasForm);
107
+ if (!form) {
108
+ const e = s.buttons(entry)[0];
109
+ if (e) {
110
+ await s.tap(e);
111
+ form = hasForm(s.screen);
112
+ }
113
+ else {
114
+ const hop = await locate(s, (t) => (elements(t).find((x) => entry.test(x.name)) ? t : null), /account|profile|menu/i);
115
+ if (hop) {
116
+ await s.tap(elements(s.screen).find((x) => entry.test(x.name)));
117
+ form = hasForm(s.screen);
118
+ }
119
+ }
120
+ }
121
+ if (!form)
122
+ return { status: "not_found", message: `No ${kind === "signup" ? "sign up" : "log in"} form found on the first screen or one tap away`, steps: s.steps };
123
+ await s.fill(invalid);
124
+ const btn = submitBtn(s, submit);
125
+ if (!btn)
126
+ throw new Fail("no submit button after filling the form");
127
+ await s.tap(btn);
128
+ const left = !stillForm(s.screen, true) && !REJECTED.test(s.screen);
129
+ if (invalid)
130
+ return left ? { status: "failed", message: "The app accepted invalid input and moved on", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps } : { status: "passed", message: "The app rejected invalid input and stayed on the form", steps: s.steps };
131
+ return left ? { status: "passed", message: "The form was accepted and the app moved on", steps: s.steps } : { status: "failed", message: `The app did not accept valid input${REJECTED.exec(s.screen) ? ` (screen shows "${REJECTED.exec(s.screen)[0]}")` : ""}`, step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps };
132
+ }
133
+ async function searchFlow(s, invalid) {
134
+ const isSearch = (t) => fieldsOf(t).find((f) => /search|find|query/i.test(f.name)) ?? null;
135
+ await s.open();
136
+ const f = await locate(s, isSearch, /search|find/i);
137
+ if (!f)
138
+ return { status: "not_found", message: "No search field found on the first screen or one tap away", steps: s.steps };
139
+ // The typed text shows in the field itself, so leave the field lines out of every check.
140
+ const noFields = (t) => signature(t.split("\n").filter((l) => !/^\s*- (textbox|searchbox)\b/.test(l)).join("\n"));
141
+ const before = noFields(s.screen);
142
+ const field = isSearch(s.screen);
143
+ await s.type(field, sampleValue(field.name, invalid), true);
144
+ if (invalid)
145
+ return { status: "passed", message: "An empty search did not crash the app", steps: s.steps };
146
+ const go = noFields(s.screen) === before ? submitBtn(s, /^(search|find|go)$/i) : undefined;
147
+ if (go)
148
+ await s.tap(go);
149
+ return noFields(s.screen) !== before || noFields(s.screen).includes(sampleValue(field.name, false))
150
+ ? { status: "passed", message: "The screen responded to the search", steps: s.steps }
151
+ : { status: "failed", message: "The screen did not change after searching", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps };
152
+ }
153
+ async function addToCart(s, invalid) {
154
+ const addIn = (t) => (elements(t).find((e) => ADD_BTN.test(e.name)) ? t : null);
155
+ if (!(await locate(s, addIn, PRODUCT)))
156
+ return null;
157
+ const qty = fieldsOf(s.screen).find((f) => /qty|quantity/i.test(f.name));
158
+ if (invalid) {
159
+ if (!qty)
160
+ return { status: "not_run", message: "No quantity field to give a bad value", steps: s.steps };
161
+ await s.type(qty, "abc");
162
+ await s.tap(elements(s.screen).find((e) => ADD_BTN.test(e.name)));
163
+ return ADDED.test(s.screen) && /abc/.test(s.screen) ? { status: "failed", message: "The app added a quantity of \"abc\"", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps } : { status: "passed", message: "A bad quantity did not crash the app or get added", steps: s.steps };
164
+ }
165
+ const before = signature(s.screen);
166
+ await s.tap(elements(s.screen).find((e) => ADD_BTN.test(e.name)));
167
+ return signature(s.screen) !== before || ADDED.test(s.screen)
168
+ ? { status: "passed", message: "The add button changed the screen", steps: s.steps }
169
+ : { status: "failed", message: "Nothing changed after the add button", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps };
170
+ }
171
+ async function cartFlow(s, invalid) {
172
+ await s.open();
173
+ const r = await addToCart(s, invalid);
174
+ if (!r)
175
+ return { status: "not_found", message: "No add-to-cart button found on the first screen or one tap away", steps: s.steps };
176
+ return r;
177
+ }
178
+ const FINAL = /place order|pay\b|pay now|confirm|submit order|complete/i;
179
+ const CHOICE = /pickup|delivery|ship|card|mobile money|cash|standard|express|any day|use my location|fingerprint/i;
180
+ const NEXT = /continue|next|proceed|review/i;
181
+ async function checkoutFlow(s, invalid) {
182
+ await s.open();
183
+ // A cart needs something in it first.
184
+ if (!(await addToCart(s, false)))
185
+ return { status: "not_found", message: "No add-to-cart button found, so there is no way to reach checkout", steps: s.steps };
186
+ const isCheckout = (t) => (elements(t).find((e) => /check ?out|choose delivery|place order|proceed to pay/i.test(e.name)) ? t : null);
187
+ if (!(await locate(s, isCheckout, CART_NAV)))
188
+ return { status: "not_found", message: "No checkout control found after adding to the cart", steps: s.steps };
189
+ const tapped = new Set();
190
+ let submitted = false;
191
+ for (let i = 0; i < 10; i++) {
192
+ if (ORDER_DONE.test(s.screen))
193
+ return invalid ? { status: "failed", message: "The order went through with invalid input", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps } : { status: "passed", message: "The order was placed", steps: s.steps };
194
+ // Invalid input: once a submit leaves the form in place, the app rejected it. Picking another option (such as pickup) would skip the bad field.
195
+ if (invalid && submitted && fieldsOf(s.screen).length)
196
+ break;
197
+ await s.fill(invalid);
198
+ const key = (e) => `${signature(s.screen)}|${e.role}|${e.name}|${e.nth}`;
199
+ const cands = [...s.buttons(FINAL), ...s.buttons(CHOICE), ...s.buttons(/check ?out|choose delivery/i), ...s.buttons(NEXT)].filter((e) => !tapped.has(key(e)) && !FIELD_ROLES.has(e.role));
200
+ const next = cands[0];
201
+ if (!next)
202
+ break;
203
+ tapped.add(key(next));
204
+ const hadFields = fieldsOf(s.screen).length > 0;
205
+ await s.tap(next);
206
+ if (hadFields && FINAL.test(next.name))
207
+ submitted = true;
208
+ }
209
+ if (ORDER_DONE.test(s.screen))
210
+ return invalid ? { status: "failed", message: "The order went through with invalid input", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps } : { status: "passed", message: "The order was placed", steps: s.steps };
211
+ if (invalid)
212
+ return { status: "passed", message: "Checkout did not complete with invalid input", steps: s.steps };
213
+ return { status: "failed", message: "Checkout stopped before the order was placed: no control left to continue", step: describeStep(s.steps[s.steps.length - 1]), steps: s.steps };
214
+ }
215
+ async function once(d, flow, invalid) {
216
+ const s = new Session(d);
217
+ try {
218
+ if (flow === "signup" || flow === "login")
219
+ return await authFlow(s, flow, invalid);
220
+ if (flow === "search")
221
+ return await searchFlow(s, invalid);
222
+ if (flow === "add_to_cart")
223
+ return await cartFlow(s, invalid);
224
+ return await checkoutFlow(s, invalid);
225
+ }
226
+ catch (e) {
227
+ if (!(e instanceof Fail))
228
+ throw e;
229
+ return { status: "failed", message: e.message, step: s.steps.length ? describeStep(s.steps[s.steps.length - 1]) : undefined, steps: s.steps };
230
+ }
231
+ }
232
+ /** Turn the `--flows` value into flow names. A bare flag (the arg parser gives "true") means all of them. */
233
+ export function parseFlowNames(v) {
234
+ if (v === "true")
235
+ return [...FLOW_NAMES];
236
+ const names = v.split(",").map((x) => x.trim().replace(/-/g, "_")).filter(Boolean);
237
+ const bad = names.find((n) => !FLOW_NAMES.includes(n));
238
+ if (bad)
239
+ throw new Error(`--flows: unknown flow "${bad}". Use ${FLOW_NAMES.join(", ")}`);
240
+ return names;
241
+ }
242
+ /** Run each flow with valid input, then once with invalid input. Each run starts on a fresh phone. */
243
+ export async function runFlows(d, o = {}) {
244
+ const out = [];
245
+ for (const flow of o.flows ?? FLOW_NAMES) {
246
+ const valid = await once(d, flow, false).catch((e) => ({ status: "failed", message: `The flow stopped: ${e instanceof Error ? e.message : String(e)}`, steps: [] }));
247
+ const status = valid.status === "not_run" ? "not_found" : valid.status;
248
+ const invalid = status === "not_found" ? { status: "not_run", message: "The flow was not found", steps: [] } : await once(d, flow, true).catch((e) => ({ status: "failed", message: `The flow stopped: ${e instanceof Error ? e.message : String(e)}`, steps: [] }));
249
+ out.push({ flow, status, message: valid.message, step: valid.step, steps: valid.steps, invalid });
250
+ }
251
+ return { app: o.app ?? "", generatedAt: new Date().toISOString(), flows: out };
252
+ }
253
+ export function renderFlows(r) {
254
+ const L = [`# Flow report: ${r.app}`, "", "| Flow | Valid input | Invalid input |", "| --- | --- | --- |"];
255
+ for (const f of r.flows)
256
+ L.push(`| ${f.flow.replaceAll("_", " ")} | ${f.status} | ${f.invalid.status === "not_run" ? "not run" : f.invalid.status} |`);
257
+ L.push("");
258
+ for (const f of r.flows) {
259
+ L.push(`## ${f.flow.replaceAll("_", " ")}`, "", `- Valid input: ${f.status}. ${f.message}.${f.step ? ` Failed at: ${f.step}.` : ""}`, `- Invalid input: ${f.invalid.status === "not_run" ? "not run" : f.invalid.status}. ${f.invalid.message}.${f.invalid.step ? ` Failed at: ${f.invalid.step}.` : ""}`, "");
260
+ if (f.status === "failed")
261
+ L.push("Steps to reproduce:", "", ...f.steps.map((s, i) => `${i + 1}. ${describeStep(s)}`), "");
262
+ }
263
+ return L.join("\n");
264
+ }
package/dist/help.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export declare const COMMANDS: readonly ["init", "sim", "run", "explore", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "audit", "test", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
2
- export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.\n Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.\n run android|ios [--no-open] [--port n] Open the app in a phone frame in your browser (no Playwright)\n run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios\n explore [--max-depth 3] [--max-steps 100] [--fail-on-findings] Walk the app in the simulator, write explore-report.md and .json\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] [--release N] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n promote --report <file> Refuse to widen when the test report has a failing flow for this release\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)\n eas build [args] Link the iOS scheme, then run eas build\n login Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]\n Pick the store accounts to open and write store-plan.json\n store screenshots [--out dir] [--sim url]\n Render each route at store sizes (fast mode: simulator, not the native UI)\n store testers [--package id] [--track alpha] [--play-key file] [--json]\n Steps to get your 12 testers; add the Shipvo Testers group to the closed track\n store status [--mark <step> [--as waiting|done]]\n Show each store's steps and what to do next\n ci init --framework capacitor [--dry-run] [--force]\n Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)\n status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)\n agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents\n audit <app> [--format json|csv] [--limit n] Export the audit log to stdout\n test [files] [--repair [--write]] [--report <file> [--release <id>]] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release\n doctor [--port <n>] Check setup before first publish\n keys Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
1
+ export declare const COMMANDS: readonly ["init", "sim", "smoke", "run", "explore", "lumi", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "skills", "audit", "migrate", "approvals", "test", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
2
+ export declare const help = "shipvo <command>\n\nStart\n init <dir> [--json] Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.\n Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.\n smoke [android|ios] [--timeout 30] [--json] Check the app loads in the simulator as the Android (default) or iPhone-style phone: no thrown errors, a first screen with elements (exit 0 ok, 1 failed, 2 setup error)\n run android|ios [--no-open] [--port n] [--lan] Open the app in a phone frame in your browser (no Playwright)\n --lan also serves it to a phone on your Wi-Fi: URL + QR code, token required\n run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios\n explore [--max-depth 3] [--max-steps 100] [--seed 1] [--flows [signup,login,search,add-to-cart,checkout]] [--fail-on-findings] [--changed-from <sha>] [--issues-dir <dir>] [--json] Walk the app in the simulator, write explore-report.md and .json\n lumi [android|ios] [--ui-port 4547] [--no-open] Open the app on Shipvo Phone with the Lumi sidebar: live feed, findings, coverage, and a command box (test my app, test <flow>, stop, replay bug <n>)\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] [--release N] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n promote --report <file> Refuse to widen when the test report has a failing flow for this release\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)\n eas build [args] Link the iOS scheme, then run eas build\n login [--json] Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]\n Pick the store accounts to open and write store-plan.json\n store screenshots [--out dir] [--sim url]\n Render each route at store sizes (fast mode: simulator, not the native UI)\n store testers [--package id] [--track alpha] [--play-key file] [--json]\n Steps to get your 12 testers; add the Shipvo Testers group to the closed track\n store status [--mark <step> [--as waiting|done]]\n Show each store's steps and what to do next\n ci init --check Write .github/workflows/shipvo-check.yml (test and explore on every pull request)\n ci init --framework capacitor [--dry-run] [--force]\n Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)\n status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)\n agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents, and copy the skills of your @shipvo/* dependencies\n skills sync [--dry-run] [--json] Only copy the skills of your @shipvo/* dependencies into .agents/skills and .claude/skills\n approvals list|approve <id> [--app id] [--json] List promotes held for a second owner, or approve one\n audit <app> [--format json|csv] [--limit n] [--json] Export the audit log to stdout\n test [files] [--repair [--write]] [--report <file> [--release <id>] [--platform android|ios] [--upload]] [--json] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release\n doctor [--port <n>] [--json] Check setup before first publish\n migrate --plan [--json] Read-only plan to switch from Expo EAS Update (expo-updates) to Shipvo\n migrate --apply [--force] [--json] Make the safe edits (EAS Update or CodePush) (backups in .shipvo/migrate-backup/)\n keys [--json] Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n keys rotate sign|upload Sign a key rotation with the root key (offline), upload it\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
3
3
  export declare function distance(a: string, b: string): number;
4
4
  /** Closest command within edit distance 2, or undefined. */
5
5
  export declare function suggest(input: string): string | undefined;
package/dist/help.js CHANGED
@@ -1,18 +1,21 @@
1
1
  export const COMMANDS = [
2
- "init", "sim", "run", "explore", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "audit", "test", "keys", "mcp",
2
+ "init", "sim", "smoke", "run", "explore", "lumi", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "skills", "audit", "migrate", "approvals", "test", "keys", "mcp",
3
3
  "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify",
4
4
  ];
5
5
  export const help = `shipvo <command>
6
6
 
7
7
  Start
8
- init <dir> Create a new app wired to Shipvo
8
+ init <dir> [--json] Create a new app wired to Shipvo
9
9
  sim [--port 4545] Build the app and run it in the simulator
10
10
  sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors
11
11
  First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.
12
12
  Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.
13
- run android|ios [--no-open] [--port n] Open the app in a phone frame in your browser (no Playwright)
13
+ smoke [android|ios] [--timeout 30] [--json] Check the app loads in the simulator as the Android (default) or iPhone-style phone: no thrown errors, a first screen with elements (exit 0 ok, 1 failed, 2 setup error)
14
+ run android|ios [--no-open] [--port n] [--lan] Open the app in a phone frame in your browser (no Playwright)
15
+ --lan also serves it to a phone on your Wi-Fi: URL + QR code, token required
14
16
  run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios
15
- explore [--max-depth 3] [--max-steps 100] [--fail-on-findings] Walk the app in the simulator, write explore-report.md and .json
17
+ explore [--max-depth 3] [--max-steps 100] [--seed 1] [--flows [signup,login,search,add-to-cart,checkout]] [--fail-on-findings] [--changed-from <sha>] [--issues-dir <dir>] [--json] Walk the app in the simulator, write explore-report.md and .json
18
+ lumi [android|ios] [--ui-port 4547] [--no-open] Open the app on Shipvo Phone with the Lumi sidebar: live feed, findings, coverage, and a command box (test my app, test <flow>, stop, replay bug <n>)
16
19
  preview [--json] Publish a public 24-hour preview link (here.now, no account)
17
20
 
18
21
  Ship
@@ -27,7 +30,7 @@ Ship
27
30
  app create | list | delete Register, list or remove apps on the hosted control plane
28
31
  eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)
29
32
  eas build [args] Link the iOS scheme, then run eas build
30
- login Sign in with GitHub and get a key for the hosted simulator
33
+ login [--json] Sign in with GitHub and get a key for the hosted simulator
31
34
  store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json
32
35
  store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]
33
36
  Pick the store accounts to open and write store-plan.json
@@ -37,16 +40,22 @@ Ship
37
40
  Steps to get your 12 testers; add the Shipvo Testers group to the closed track
38
41
  store status [--mark <step> [--as waiting|done]]
39
42
  Show each store's steps and what to do next
43
+ ci init --check Write .github/workflows/shipvo-check.yml (test and explore on every pull request)
40
44
  ci init --framework capacitor [--dry-run] [--force]
41
45
  Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)
42
46
  status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)
43
- agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents
44
- audit <app> [--format json|csv] [--limit n] Export the audit log to stdout
45
- test [files] [--repair [--write]] [--report <file> [--release <id>]] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release
46
- doctor [--port <n>] Check setup before first publish
47
- keys Show the stored key's status and quota
47
+ agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents, and copy the skills of your @shipvo/* dependencies
48
+ skills sync [--dry-run] [--json] Only copy the skills of your @shipvo/* dependencies into .agents/skills and .claude/skills
49
+ approvals list|approve <id> [--app id] [--json] List promotes held for a second owner, or approve one
50
+ audit <app> [--format json|csv] [--limit n] [--json] Export the audit log to stdout
51
+ test [files] [--repair [--write]] [--report <file> [--release <id>] [--platform android|ios] [--upload]] [--json] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release
52
+ doctor [--port <n>] [--json] Check setup before first publish
53
+ migrate --plan [--json] Read-only plan to switch from Expo EAS Update (expo-updates) to Shipvo
54
+ migrate --apply [--force] [--json] Make the safe edits (EAS Update or CodePush) (backups in .shipvo/migrate-backup/)
55
+ keys [--json] Show the stored key's status and quota
48
56
  keys export [--out f] Print or save this app's signing seed, to back it up
49
57
  keys import [seed|--file f] Restore the signing seed on a new machine
58
+ keys rotate sign|upload Sign a key rotation with the root key (offline), upload it
50
59
  mcp Print the MCP config that agents need
51
60
 
52
61
  Advanced
package/dist/hermes.d.ts CHANGED
@@ -9,3 +9,13 @@ export declare function parseDeclared(v: unknown): number[] | undefined;
9
9
  * Throws when the bundle is not bytecode but the app declares a bytecode version, or when the versions differ.
10
10
  */
11
11
  export declare function checkBundle(file: string, bytes: Uint8Array, declared?: number[]): number | undefined;
12
+ /** Validate a Hermes engine build id such as "250829098.0.10". Undefined, null and "" mean "not declared". */
13
+ export declare function parseEngine(v: unknown): string | undefined;
14
+ /**
15
+ * The engine build of the Hermes compiler this project uses: the version of the `hermes-compiler` package
16
+ * (React Native 0.83+ depends on it), looked up from the project, then from react-native's own install.
17
+ * Returns undefined when it cannot be found or the version is not an engine id.
18
+ */
19
+ export declare function installedHermesEngine(cwd: string): string | undefined;
20
+ /** Refuse a publish when the app declares an engine build and the compiler in use is another one (or cannot be identified). Returns the id to record. */
21
+ export declare function checkEngine(installed: string | undefined, declared: string | undefined): string | undefined;
package/dist/hermes.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
1
4
  // Read the Hermes bytecode version from a bundle header, and check it against what the app's shells declare.
2
5
  // A Hermes bytecode file starts with an 8-byte magic number (little-endian 0x1F1903C103BC1FC6),
3
6
  // followed by the bytecode format version as a 32-bit little-endian integer.
@@ -39,3 +42,50 @@ export function checkBundle(file, bytes, declared) {
39
42
  throw new Error(`${file} is Hermes bytecode version ${version}, but the app's shells run ${declared.join(", ")}. Build with the same Hermes as the shell, or update hermesBytecodeVersion if the shell changed.`);
40
43
  return version;
41
44
  }
45
+ const ENGINE_ID = /^\d+\.\d+\.\d+$/;
46
+ /** Validate a Hermes engine build id such as "250829098.0.10". Undefined, null and "" mean "not declared". */
47
+ export function parseEngine(v) {
48
+ if (v === undefined || v === null || v === "")
49
+ return undefined;
50
+ if (typeof v !== "string" || !ENGINE_ID.test(v.trim()))
51
+ throw new Error(`hermesEngine must look like 250829098.0.10 (got ${JSON.stringify(v)})`);
52
+ return v.trim();
53
+ }
54
+ /**
55
+ * The engine build of the Hermes compiler this project uses: the version of the `hermes-compiler` package
56
+ * (React Native 0.83+ depends on it), looked up from the project, then from react-native's own install.
57
+ * Returns undefined when it cannot be found or the version is not an engine id.
58
+ */
59
+ export function installedHermesEngine(cwd) {
60
+ const from = (base) => { try {
61
+ return createRequire(join(base, "package.json")).resolve("hermes-compiler/package.json");
62
+ }
63
+ catch {
64
+ return undefined;
65
+ } };
66
+ let path = from(cwd);
67
+ if (!path) {
68
+ try {
69
+ path = from(dirname(createRequire(join(cwd, "package.json")).resolve("react-native/package.json")));
70
+ }
71
+ catch { }
72
+ }
73
+ if (!path)
74
+ return undefined;
75
+ try {
76
+ const v = JSON.parse(readFileSync(path, "utf8")).version;
77
+ return typeof v === "string" && ENGINE_ID.test(v) ? v : undefined;
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ /** Refuse a publish when the app declares an engine build and the compiler in use is another one (or cannot be identified). Returns the id to record. */
84
+ export function checkEngine(installed, declared) {
85
+ if (declared && installed !== declared) {
86
+ throw new Error(installed
87
+ ? `The app declares Hermes engine ${declared} (hermesEngine in shipvo.json), but hermes-compiler here is ${installed}. Build with the same Hermes as the shell, or update hermesEngine if the shell changed.`
88
+ : `The app declares Hermes engine ${declared} (hermesEngine in shipvo.json), but the hermes-compiler version could not be read from this project. Install hermes-compiler, or remove hermesEngine.`);
89
+ }
90
+ return installed;
91
+ }
package/dist/init.d.ts CHANGED
@@ -1 +1,2 @@
1
+ export declare const INIT_HELP: string;
1
2
  export declare function init(argv: string[]): Promise<void>;
package/dist/init.js CHANGED
@@ -1,25 +1,26 @@
1
1
  // `shipvo init <dir>`: a new Expo app wired to Shipvo: signed OTA runtime + plugin, the device API, the simulator,
2
2
  // a publish workflow and a shipvo.json. Everything an agent needs to open it is in place on the first commit.
3
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { join, resolve } from "node:path";
6
6
  import { b64, keyFromSeed } from "./index.js";
7
7
  import { args as parse } from "./config.js";
8
8
  import { adopt, detectProject, isAdoptArgs, isBareApp, positional } from "./adopt.js";
9
+ import { CliFailure, UsageError, runJson, trapExit } from "./json-out.js";
9
10
  const HOSTED = "https://api.shipvo.dev";
10
11
  const SDK = { expo: "~58.0.21", camera: "~58.0.14", clipboard: "~58.0.4", localAuth: "~58.0.2", location: "~58.0.20", notifications: "~58.1.7", statusBar: "~58.0.4", constants: "~58.0.0", metroRuntime: "~58.0.15", react: "19.3.0", rn: "0.88.1", rnw: "^0.21.4" };
11
12
  const TEMPLATES = ["blank", "content", "shop"];
12
13
  const INIT_NEEDS_DIR = `shipvo: init needs a folder name.
13
14
  Try: shipvo init myshop
14
15
  This creates ./myshop with a ready app. Other options (app id, bundle id, control plane) have defaults; see 'shipvo init --help'.`;
15
- const INIT_HELP = `Usage: shipvo init <dir> [options]
16
+ export const INIT_HELP = `Usage: shipvo init <dir> [options]
16
17
  shipvo init [.] [--dry-run] [--yes] [--device] (adopt mode, inside an existing Expo or bare React Native app)
17
18
 
18
19
  Adopt mode: run it inside an Expo app (package.json with expo, plus app.json or app.config.js/ts).
19
20
  It shows a plan, then applies it after you confirm. It installs @shipvo/runtime, adds the config plugin
20
21
  and a signing key, writes shipvo.json, adds sim/publish/rollback scripts, and adds a publish workflow if
21
22
  there is none. It never overwrites a file and never touches ios/ or android/. It refuses apps that use
22
- expo-updates or CodePush. See docs/cli.md.
23
+ expo-updates or CodePush; for expo-updates, run shipvo migrate --plan. See docs/cli.md.
23
24
 
24
25
  In a bare React Native app (ios/ and android/ committed, no Expo config plugins) it also writes the Shipvo
25
26
  values into AndroidManifest.xml and Info.plist, and runs npx install-expo-modules@latest if Expo Modules are
@@ -42,9 +43,44 @@ Creates <dir> with a ready app. Every option is optional.
42
43
  blank the starter with device API buttons.
43
44
  content a list screen and a detail screen reading titles and text from src/data/articles.json.
44
45
  shop a product list, a product page and a cart, reading products and prices from src/data/products.json.
46
+ --json Print one JSON envelope on stdout; progress goes to stderr.
45
47
  --help Show this text.`;
46
48
  const title = (s) => s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
49
+ /** `shipvo init --json`: the same work, reported as one envelope. The seed is never in it; only the path of the seed file. */
50
+ async function initJson(argv) {
51
+ const [dirArg] = positional(argv.filter((x) => x !== "--json"));
52
+ const dry = argv.includes("--dry-run");
53
+ return runJson(async () => {
54
+ const adoptMode = isAdoptArgs(argv) && (dirArg === "." || detectProject(process.cwd()).isExpo || isBareApp(process.cwd()));
55
+ if (adoptMode && !argv.includes("--yes") && !dry)
56
+ throw new UsageError("init --json in an existing app needs --yes to apply the plan, or --dry-run to only print it.");
57
+ await trapExit(() => init(argv.filter((x) => x !== "--json")), (m, st) => {
58
+ const code = st === 2 ? "usage" : /already exists/.test(m) ? "dir_exists" : "init_failed";
59
+ return new CliFailure(m, code, st, code === "usage" ? [{ command: "shipvo init --help", why: "See the options." }] : code === "dir_exists" ? [{ command: "shipvo init <new-dir> --json", why: "Pick a folder that does not exist." }] : [{ command: "shipvo doctor --json", why: "Check your setup." }]);
60
+ });
61
+ const dir = adoptMode ? process.cwd() : resolve(dirArg);
62
+ let cfg = {};
63
+ try {
64
+ cfg = JSON.parse(readFileSync(join(dir, "shipvo.json"), "utf8"));
65
+ }
66
+ catch { }
67
+ const seedFile = join(dir, ".shipvo", "seed.txt");
68
+ const cd = adoptMode ? [] : [`cd ${dirArg} && `];
69
+ return {
70
+ code: dry ? "dry_run" : adoptMode ? "adopted" : "created",
71
+ summary: dry ? "Printed the plan. Nothing was changed." : adoptMode ? `Added Shipvo to this app${cfg.appId ? ` ("${cfg.appId}")` : ""}.` : `Created "${cfg.appId}" in ${dirArg}.`,
72
+ data: { mode: adoptMode ? "adopt" : "create", dry_run: dry, dir, app_id: cfg.appId ?? null, control_plane: cfg.controlPlane ?? null, public_key: cfg.publicKey ?? null, seed_file: !dry && existsSync(seedFile) ? seedFile : null },
73
+ next: dry ? [{ command: "shipvo init . --yes --json", why: "Apply the plan." }] : [
74
+ { command: `${cd[0] ?? ""}npm install`, why: "Install dependencies before any shipvo command." },
75
+ { command: `${cd[0] ?? ""}npx shipvo run android`, why: "See the app on a simulated phone (npx shipvo run ios for iOS)." },
76
+ { command: `${cd[0] ?? ""}npx shipvo publish --json`, why: "Send your first update." },
77
+ ],
78
+ };
79
+ }, [{ command: "shipvo init --help", why: "See the options." }]);
80
+ }
47
81
  export async function init(argv) {
82
+ if (argv.includes("--json") && !argv.includes("--help") && !argv.includes("-h"))
83
+ return initJson(argv);
48
84
  const [dirArg] = positional(argv);
49
85
  const a = parse(argv);
50
86
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -153,7 +189,7 @@ Created ${name} in ${dirArg}
153
189
  ${seedNote}
154
190
  Public key pinned in the shell: ${publicKey}
155
191
 
156
- Phones: npx expo run:android (needs Android Studio once). iOS: npx expo run:ios, then shipvo publish --platform all
192
+ Phones: the browser phone (npx shipvo run android) needs no Android Studio. For a real build use npx expo run:android (needs Android Studio once) or npx expo run:ios, then shipvo publish --platform all
157
193
  Publish: shipvo login, then shipvo app create (hosted), or set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and SHIPVO_KV_ID (self-hosted); then npm run publish
158
194
  (self-hosting instead? see docs/self-hosting.md)
159
195
  AI agents: npx -y @shipvo/mcp points Claude / Cursor / ChatGPT at the simulator; then open_app("${appId}")
@@ -161,7 +197,7 @@ AI agents: npx -y @shipvo/mcp points Claude / Cursor / ChatGPT at the simulator;
161
197
  Next steps:
162
198
  1. cd ${dirArg}
163
199
  2. npm install # or pnpm install / bun install; needed before any shipvo command
164
- 3. npx shipvo sim # open the app on a simulated phone in your browser
200
+ 3. npx shipvo run android # the app on a simulated phone in your browser (npx shipvo run ios for iOS)
165
201
  4. npx shipvo doctor # setup checks
166
202
  5. npx shipvo app create ${appId} # register the app (run npx shipvo login first)
167
203
  6. npx shipvo publish # send your first update
@@ -360,7 +396,6 @@ jobs:
360
396
  - uses: actions/setup-node@v4
361
397
  with: { node-version: 22 }
362
398
  - run: npm ci
363
- - run: pip install --break-system-packages bsdiff4
364
399
  - name: Build, sign and publish over the air
365
400
  env:
366
401
  SHIPVO_PUBLISH_TOKEN: \${{ secrets.SHIPVO_PUBLISH_TOKEN }} # from \`shipvo app create\`; self-hosting instead: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, SHIPVO_KV_ID
@@ -0,0 +1,53 @@
1
+ export type Next = {
2
+ command: string;
3
+ why: string;
4
+ };
5
+ export type Envelope<C extends string = string, D = unknown> = {
6
+ ok: boolean;
7
+ /** Short stable string. */
8
+ code: C;
9
+ summary: string;
10
+ data: D;
11
+ /** One to three commands to run next, most urgent first. */
12
+ next: Next[];
13
+ };
14
+ /** Exit codes shared by the `--json` commands. */
15
+ export declare const EXIT: {
16
+ readonly ok: 0;
17
+ readonly failed: 1;
18
+ readonly usage: 2;
19
+ };
20
+ /** Print the envelope as one line on stdout. */
21
+ export declare function emit(doc: Envelope<string, unknown>): void;
22
+ /** Build an envelope. `ok` is true exactly when the exit code is 0. */
23
+ export declare function envelope<C extends string, D>(code: C, summary: string, data: D, next: Next[], exit: number): Envelope<C, D>;
24
+ /** A failure with a stable code, an exit code and a suggested follow-up. Without `--json` it is a plain error. */
25
+ export declare class CliFailure extends Error {
26
+ readonly code: string;
27
+ readonly exit: number;
28
+ readonly next: Next[];
29
+ constructor(message: string, code?: string, exit?: number, next?: Next[]);
30
+ }
31
+ /** A bad flag or value. Exit code 2. */
32
+ export declare class UsageError extends CliFailure {
33
+ constructor(message: string);
34
+ }
35
+ /** What a command reports on success in `--json` mode. */
36
+ export type Done = {
37
+ code: string;
38
+ summary: string;
39
+ data: unknown;
40
+ next: Next[];
41
+ };
42
+ /**
43
+ * Run `fn` so that stdout holds only the envelope: console.log goes to stderr while it runs.
44
+ * A thrown error becomes a failure envelope (exit 2 for a UsageError, 1 otherwise). Sets process.exitCode.
45
+ */
46
+ export declare function runJson(fn: () => Promise<Done>, fallbackNext: Next[]): Promise<void>;
47
+ /**
48
+ * Run a text-mode command that ends with `process.exit` after printing to stderr. An exit becomes a thrown
49
+ * CliFailure built by `classify(message, status)`; the stderr text is still written. Status 0 returns normally.
50
+ */
51
+ export declare function trapExit(fn: () => Promise<unknown>, classify: (message: string, status: number) => CliFailure): Promise<void>;
52
+ /** Collect what `fn` prints with console.log instead of printing it. Used so output that holds a secret never reaches a stream. */
53
+ export declare function captureLog(fn: () => Promise<unknown>): Promise<string[]>;