@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/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")) {
@@ -97,7 +133,7 @@ export async function init(argv) {
97
133
  react: SDK.react, "react-dom": SDK.react, "react-native": SDK.rn, "react-native-web": SDK.rnw,
98
134
  "@shipvo/device": "^0.1.0", "@shipvo/runtime": "^0.1.0",
99
135
  },
100
- devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: "^1.63.0", typescript: "~6.0.3", vitest: "^2.1.4" },
136
+ devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: ">=1.63.0 <1.65.0", typescript: "~6.0.3", vitest: "^2.1.4" },
101
137
  }, null, 2) + "\n");
102
138
  w("app.json", JSON.stringify({ expo: {
103
139
  name, slug: appId, version: "1.0.0", orientation: "portrait", userInterfaceStyle: "automatic",
@@ -133,7 +169,7 @@ export const RELEASE = { headline: "Hello from ${name}.", note: "Edit src/releas
133
169
  w("App.tsx", SHOP_APP_TSX(name));
134
170
  w("src/cart.tsx", SHOP_CART_STATE);
135
171
  w("src/money.ts", SHOP_MONEY);
136
- w("src/data/shop.json", SHOP_CONFIG);
172
+ w("src/data/shop.json", SHOP_CONFIG(name));
137
173
  w("src/screens/List.tsx", SHOP_LIST);
138
174
  w("src/screens/Detail.tsx", SHOP_DETAIL);
139
175
  w("src/screens/Cart.tsx", SHOP_CART);
@@ -153,19 +189,19 @@ 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}")
160
196
 
161
- Install first: npm install (or pnpm / bun), then npx playwright install chromium
162
-
163
197
  Next steps:
164
198
  1. cd ${dirArg}
165
- 2. npx shipvo doctor # setup checks
166
- 3. npm run sim # open the app in the simulator on http://localhost:4545
167
- 4. npx shipvo publish --dry-run # see what a release would contain
168
- 5. npx shipvo preview # get a link you can open on any phone
199
+ 2. npm install # or pnpm install / bun install; needed before any shipvo command
200
+ 3. npx shipvo run android # the app on a simulated phone in your browser (npx shipvo run ios for iOS)
201
+ 4. npx shipvo doctor # setup checks
202
+ 5. npx shipvo app create ${appId} # register the app (run npx shipvo login first)
203
+ 6. npx shipvo publish # send your first update
204
+ 7. npx shipvo preview # get a link you can open on any phone
169
205
  `);
170
206
  }
171
207
  const APP_TSX = (name, appId) => `import { useEffect, useRef, useState } from "react";
@@ -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
@@ -482,7 +517,7 @@ const SHOP_DATA = JSON.stringify([
482
517
  { id: "wool-beanie", name: "Wool beanie", price: 2400, description: "A warm hat in undyed wool." },
483
518
  { id: "steel-bottle", name: "Steel bottle", price: 2900, description: "A one litre bottle with a screw cap." },
484
519
  ], null, 2) + "\n";
485
- const SHOP_CONFIG = JSON.stringify({ currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
520
+ const SHOP_CONFIG = (name) => JSON.stringify({ name, currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
486
521
  const SHOP_MONEY = `// Prices are whole minor units (cents) in src/data/products.json. This is the one place that turns them into text.
487
522
  // To change the currency, edit src/data/shop.json. "code" is the ISO 4217 code sent to the payment provider ("$" is only a symbol). For FCFA: { "currency": "FCFA", "code": "XAF", "position": "after", "decimals": 0, "locale": "fr-FR" }
488
523
  import shop from "./data/shop.json";
@@ -496,6 +531,9 @@ export const formatPrice = (minor: number) => {
496
531
  return position === "after" ? num + " " + currency : currency + num;
497
532
  };
498
533
 
534
+ // The payee shown on the payment sheet and the receipt. It is "name" in shop.json.
535
+ export const shopName = (shop as { name?: string }).name ?? "Shop";
536
+
499
537
  // What device.pay.charge takes: "amount" is in whole currency units (10710 for 10710 FCFA, 12.5 for $12.50), as in the device docs.
500
538
  // Prices are minor units, so divide by 10 ** decimals. "currency" is the ISO code from shop.json.
501
539
  export const chargeRequest = (minor: number) => {
@@ -538,6 +576,15 @@ import products from "./src/data/products.json";
538
576
 
539
577
  type Route = { name: "list" } | { name: "detail"; id: string } | { name: "cart" };
540
578
 
579
+ // Send a placed order to the backend. The simulator records it, so end_session lists it. Add your own backend call here.
580
+ async function sendOrder(order: Record<string, unknown>) {
581
+ try {
582
+ const st = Runtime.getState();
583
+ if (!st?.baseUrl) return;
584
+ await fetch(st.baseUrl + "/o/" + st.appId, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order, release: st.runningRelease ?? 0 }) });
585
+ } catch {}
586
+ }
587
+
541
588
  export default function App() {
542
589
  const [route, setRoute] = useState<Route>({ name: "list" });
543
590
  // Tell the runtime the first screen rendered, so this release is kept.
@@ -560,7 +607,7 @@ export default function App() {
560
607
  <CartProvider>
561
608
  <View style={s.root}>
562
609
  <StatusBar style="dark" />
563
- {route.name === "cart" ? <Cart onBack={toList} />
610
+ {route.name === "cart" ? <Cart onBack={toList} onOrder={sendOrder} />
564
611
  : product ? <Detail product={product} onBack={toList} onCart={toCart} />
565
612
  : <List title="${name}" products={products} onOpen={(id) => setRoute({ name: "detail", id })} onCart={toCart} />}
566
613
  </View>
@@ -637,10 +684,10 @@ const SHOP_CART = `import { useState } from "react";
637
684
  import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
638
685
  import { device } from "@shipvo/device";
639
686
  import { useCart } from "../cart";
640
- import { chargeRequest, formatPrice } from "../money";
687
+ import { chargeRequest, formatPrice, shopName } from "../money";
641
688
  import products from "../data/products.json";
642
689
 
643
- export function Cart({ onBack }: { onBack: () => void }) {
690
+ export function Cart({ onBack, onOrder }: { onBack: () => void; onOrder?: (order: Record<string, unknown>) => void | Promise<void> }) {
644
691
  const { lines, remove, clear, totalCents } = useCart();
645
692
  const [status, setStatus] = useState<string | null>(null);
646
693
  const [busy, setBusy] = useState(false);
@@ -650,8 +697,13 @@ export function Cart({ onBack }: { onBack: () => void }) {
650
697
  setBusy(true);
651
698
  setStatus("Paying...");
652
699
  try {
653
- const r = await device.pay.charge({ provider: "mobile_money", ...chargeRequest(totalCents), to: "My Shop", reference: "order-" + Date.now() }, "1234");
654
- if (r.ok) { clear(); setStatus("Order placed"); } else setStatus("Payment failed: " + r.reason);
700
+ const reference = "order-" + Date.now();
701
+ const r = await device.pay.charge({ provider: "mobile_money", ...chargeRequest(totalCents), to: shopName, reference }, "1234");
702
+ if (r.ok) {
703
+ // Only a paid order is sent to the backend.
704
+ await onOrder?.({ id: reference, total: totalCents, payment: "mobile_money", receipt: r.receipt, lines: lines.map((l) => ({ product_id: l.id, quantity: l.qty })) });
705
+ clear(); setStatus("Order placed");
706
+ } else setStatus("Payment failed: " + r.reason);
655
707
  } catch (e: any) { setStatus("Payment failed: " + String(e?.message ?? e)); }
656
708
  finally { setBusy(false); }
657
709
  };
@@ -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[]>;
@@ -0,0 +1,91 @@
1
+ /** Exit codes shared by the `--json` commands. */
2
+ export const EXIT = { ok: 0, failed: 1, usage: 2 };
3
+ /** Print the envelope as one line on stdout. */
4
+ export function emit(doc) {
5
+ console.log(JSON.stringify(doc));
6
+ }
7
+ /** Build an envelope. `ok` is true exactly when the exit code is 0. */
8
+ export function envelope(code, summary, data, next, exit) {
9
+ return { ok: exit === 0, code, summary, data, next: next.slice(0, 3) };
10
+ }
11
+ /** A failure with a stable code, an exit code and a suggested follow-up. Without `--json` it is a plain error. */
12
+ export class CliFailure extends Error {
13
+ code;
14
+ exit;
15
+ next;
16
+ constructor(message, code = "failed", exit = EXIT.failed, next = []) {
17
+ super(message);
18
+ this.code = code;
19
+ this.exit = exit;
20
+ this.next = next;
21
+ }
22
+ }
23
+ /** A bad flag or value. Exit code 2. */
24
+ export class UsageError extends CliFailure {
25
+ constructor(message) { super(message, "usage", EXIT.usage, [{ command: "shipvo help", why: "See the commands and flags." }]); }
26
+ }
27
+ /**
28
+ * Run `fn` so that stdout holds only the envelope: console.log goes to stderr while it runs.
29
+ * A thrown error becomes a failure envelope (exit 2 for a UsageError, 1 otherwise). Sets process.exitCode.
30
+ */
31
+ export async function runJson(fn, fallbackNext) {
32
+ const log = console.log;
33
+ console.log = console.error;
34
+ let doc, exit;
35
+ try {
36
+ const d = await fn();
37
+ exit = 0;
38
+ doc = envelope(d.code, d.summary, d.data, d.next, 0);
39
+ }
40
+ catch (e) {
41
+ const f = e instanceof CliFailure ? e : null;
42
+ exit = f?.exit ?? EXIT.failed;
43
+ doc = envelope(f?.code ?? "failed", e instanceof Error ? e.message : String(e), {}, f && f.next.length ? f.next : fallbackNext, exit);
44
+ }
45
+ finally {
46
+ console.log = log;
47
+ }
48
+ emit(doc);
49
+ process.exitCode = exit;
50
+ }
51
+ class ExitSignal extends Error {
52
+ status;
53
+ constructor(status) {
54
+ super("exit");
55
+ this.status = status;
56
+ }
57
+ }
58
+ /**
59
+ * Run a text-mode command that ends with `process.exit` after printing to stderr. An exit becomes a thrown
60
+ * CliFailure built by `classify(message, status)`; the stderr text is still written. Status 0 returns normally.
61
+ */
62
+ export async function trapExit(fn, classify) {
63
+ const exit = process.exit, err = console.error, msgs = [];
64
+ process.exit = ((c) => { throw new ExitSignal(c ?? 0); });
65
+ console.error = (...a) => { msgs.push(a.map(String).join(" ")); err(...a); };
66
+ try {
67
+ await fn();
68
+ }
69
+ catch (e) {
70
+ if (!(e instanceof ExitSignal))
71
+ throw e;
72
+ if (e.status !== 0)
73
+ throw classify(msgs.join("\n").trim(), e.status);
74
+ }
75
+ finally {
76
+ process.exit = exit;
77
+ console.error = err;
78
+ }
79
+ }
80
+ /** Collect what `fn` prints with console.log instead of printing it. Used so output that holds a secret never reaches a stream. */
81
+ export async function captureLog(fn) {
82
+ const log = console.log, lines = [];
83
+ console.log = (...a) => { lines.push(a.map(String).join(" ")); };
84
+ try {
85
+ await fn();
86
+ }
87
+ finally {
88
+ console.log = log;
89
+ }
90
+ return lines;
91
+ }
@@ -0,0 +1 @@
1
+ export declare function keysJson(argv: string[]): Promise<void>;
@@ -0,0 +1,71 @@
1
+ // `shipvo keys ... --json`: status, export, import and rotate as one envelope. Wraps the text commands, so their output paths are untouched.
2
+ // The seed is never in an envelope or on a stream: export needs --out, and import reports only the public key.
3
+ import { args, loadConfig } from "./config.js";
4
+ import { keys } from "./login.js";
5
+ import { signingKey } from "./signing.js";
6
+ import { CliFailure, UsageError, captureLog, runJson, trapExit } from "./json-out.js";
7
+ const LOGIN = { command: "shipvo login", why: "Sign in again." };
8
+ const FALLBACK = [{ command: "shipvo doctor --json", why: "Check your setup." }];
9
+ function project() {
10
+ try {
11
+ return loadConfig();
12
+ }
13
+ catch (e) {
14
+ throw new CliFailure(e instanceof Error ? e.message : String(e), "no_project", 1, [{ command: "shipvo init . --yes --json", why: "Add Shipvo to this app." }, { command: "shipvo init myapp --json", why: "Or start a new app." }]);
15
+ }
16
+ }
17
+ const failIn = (table, fallbackCode = "failed") => (m, st) => {
18
+ const hit = table.find(([re]) => re.test(m));
19
+ return new CliFailure(m, st === 2 ? "usage" : hit?.[1] ?? fallbackCode, st, hit?.[2] ?? FALLBACK);
20
+ };
21
+ export async function keysJson(argv) {
22
+ const [sub, ...rest] = argv.filter((x) => x !== "--json");
23
+ const rawRest = rest;
24
+ return runJson(async () => {
25
+ if (sub === undefined) {
26
+ const lines = await captureLog(() => trapExit(() => keys(), failIn([
27
+ [/Not signed in/, "not_signed_in", [LOGIN]], [/expired/, "key_expired", [LOGIN]], [/not valid any more/, "key_invalid", [LOGIN]],
28
+ ])));
29
+ const d = JSON.parse(lines.join("\n"));
30
+ return { code: "ok", summary: `Signed in as ${d.login}. Key ${d.prefix}… has used ${d.used_minutes} of ${d.quota_minutes} sandbox minutes.`, data: d, next: [{ command: "shipvo app create", why: "Register the app and save a publish token." }, { command: "shipvo doctor --json", why: "Check the setup." }] };
31
+ }
32
+ if (sub !== "export" && sub !== "import" && sub !== "rotate")
33
+ throw new UsageError(`Unknown keys command "${sub}". Use keys, keys export, keys import or keys rotate.`);
34
+ const cfg = project();
35
+ if (sub === "export") {
36
+ const out = args(rawRest).out;
37
+ if (!out || out === "true")
38
+ throw new UsageError("keys export --json needs --out <file>. The seed is never printed in an envelope.");
39
+ await trapExit(() => signingKey(["export", ...rawRest]), failIn([
40
+ [/Set SHIPVO_SIGNING_SEED/, "no_seed", [{ command: "shipvo keys import", why: "Restore the seed on this machine." }]],
41
+ [/does not match the public key/, "seed_mismatch", [{ command: "shipvo keys import", why: "Restore the seed that matches shipvo.json." }]],
42
+ ]));
43
+ return { code: "exported", summary: `Seed for "${cfg.appId}" written to ${out}. Move it to a password manager, then delete the file.`, data: { app_id: cfg.appId, public_key: cfg.publicKey, out }, next: [{ command: `shipvo keys import --file ${out}`, why: "Restore it on another machine." }] };
44
+ }
45
+ if (sub === "import") {
46
+ const lines = await captureLog(() => trapExit(() => signingKey(["import", ...rawRest]), failIn([
47
+ [/Usage: shipvo keys import/, "usage", [{ command: "shipvo keys import --file seed.txt", why: "Read the seed from a file." }]],
48
+ [/does not look like/, "invalid_seed", [{ command: "shipvo keys import --file seed.txt", why: "Use the seed that `shipvo keys export` saved." }]],
49
+ [/belongs to a different key/, "seed_mismatch", [{ command: "shipvo keys import --file seed.txt", why: "Use the seed for this app's public key." }]],
50
+ ])));
51
+ const text = lines.join("\n");
52
+ return {
53
+ code: /Already in place/.test(text) ? "unchanged" : "imported",
54
+ summary: /Already in place/.test(text) ? "The seed was already in place. Nothing to do." : `Signing key for "${cfg.appId}" restored to .shipvo/seed.txt (matches shipvo.json).`,
55
+ data: { app_id: cfg.appId, public_key: cfg.publicKey, seed_file: ".shipvo/seed.txt", gitignore_warning: /\.gitignore does not ignore/.test(text) },
56
+ next: [{ command: "shipvo login", why: "Sign in on this machine." }, { command: "shipvo app create", why: "Get a publish token on this machine." }],
57
+ };
58
+ }
59
+ // rotate sign | upload. The signed envelope is public, so it is safe in data.
60
+ const lines = await captureLog(() => trapExit(() => signingKey(["rotate", ...rawRest, "--json"]), failIn([
61
+ [/^Usage:/, "usage", [{ command: "shipvo keys rotate sign --root-seed-file <file> --new-key <public key> --sequence <n>", why: "Sign a rotation statement offline." }]],
62
+ [/Not signed in/, "not_signed_in", [LOGIN]],
63
+ [/refused the statement/, "refused", FALLBACK],
64
+ [/Could not reach/, "remote_failed", FALLBACK],
65
+ ], "rotate_failed")));
66
+ const out = JSON.parse(lines.join("\n"));
67
+ if (rest[0] === "sign")
68
+ return { code: "rotation_signed", summary: `Signed rotation statement ${args(rest).sequence} for "${cfg.appId}". Save data.envelope to a file, then upload it.`, data: { app_id: cfg.appId, envelope: out }, next: [{ command: "shipvo keys rotate upload <file>", why: "Upload the saved envelope. Needs shipvo login as the app owner." }] };
69
+ return { code: "rotation_uploaded", summary: "Uploaded. Phones switch when they next fetch it.", data: { app_id: cfg.appId, response: out }, next: [{ command: "shipvo status --json", why: "Check the app." }] };
70
+ }, FALLBACK);
71
+ }
package/dist/login.js CHANGED
@@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { args as parse } from "./config.js";
7
+ import { CliFailure, runJson } from "./json-out.js";
7
8
  const HOSTED = "https://api.shipvo.dev";
8
9
  const credFile = () => join(process.env.SHIPVO_HOME || join(homedir(), ".shipvo"), "credentials.json");
9
10
  export function savedCreds() {
@@ -15,14 +16,25 @@ export function savedCreds() {
15
16
  }
16
17
  }
17
18
  export async function login(argv) {
19
+ if (argv.includes("--json"))
20
+ return runJson(() => loginRun(argv, true), [{ command: "shipvo login --json", why: "Try signing in again." }, { command: "shipvo doctor --json", why: "Check your setup." }]);
21
+ await loginRun(argv, false);
22
+ }
23
+ /** Device sign-in. With `json`, failures throw (the envelope reports them), the device URL and code go in `data`, and the key is never printed. */
24
+ async function loginRun(argv, json) {
25
+ const again = [{ command: "shipvo login --json", why: "Start a new sign-in." }];
26
+ const fail = (msg, code) => {
27
+ if (json)
28
+ throw new CliFailure(msg, code, 1, again);
29
+ console.error(msg);
30
+ process.exit(1);
31
+ };
18
32
  const a = parse(argv);
19
33
  const control = (a["control-plane"] || process.env.SHIPVO_CONTROL || HOSTED).replace(/\/$/, "");
20
34
  const start = await fetch(`${control}/auth/device/start`, { method: "POST" });
21
35
  const s = await start.json();
22
- if (!start.ok) {
23
- console.error(`Login is not available on ${control}: ${s.message ?? s.error}`);
24
- process.exit(1);
25
- }
36
+ if (!start.ok)
37
+ fail(`Login is not available on ${control}: ${s.message ?? s.error}`, "login_unavailable");
26
38
  console.log(`\nOpen ${s.verification_uri} and enter the code:\n\n ${s.user_code}\n\nWaiting for GitHub…`);
27
39
  const deadline = Date.now() + (s.expires_in ?? 900) * 1000;
28
40
  let interval = (s.interval ?? 5) * 1000;
@@ -35,18 +47,22 @@ export async function login(argv) {
35
47
  interval += 5000;
36
48
  continue;
37
49
  }
38
- if (!r.ok) {
39
- console.error(`Login failed: ${d.status ?? d.error}`);
40
- process.exit(1);
41
- }
50
+ if (!r.ok)
51
+ fail(`Login failed: ${d.status ?? d.error}`, d.status === "expired_token" || d.status === "expired" ? "login_expired" : "login_failed");
42
52
  mkdirSync(join(credFile(), ".."), { recursive: true });
43
53
  writeFileSync(credFile(), JSON.stringify({ key: d.key, control, mcp_url: d.mcp.url, login: d.login, apps: d.apps, saved_at: new Date().toISOString() }, null, 2), { mode: 0o600 });
54
+ if (json) {
55
+ return {
56
+ code: "signed_in", summary: `Signed in as ${d.login}. The key is saved to ${credFile()}.`,
57
+ data: { login: d.login, key_prefix: d.prefix, saved_to: credFile(), quota_minutes: d.quota_minutes, expires_at: d.expires_at, apps: d.apps, mcp_url: d.mcp.url, device: { verification_uri: s.verification_uri, user_code: s.user_code } },
58
+ next: [{ command: "shipvo app create", why: "Register the app and save a publish token." }, { command: "shipvo doctor --json", why: "Check the setup." }],
59
+ };
60
+ }
44
61
  console.log(`\nSigned in as ${d.login}. Key ${d.prefix}… saved to ${credFile()} (quota: ${d.quota_minutes} sandbox minutes; expires ${String(d.expires_at).slice(0, 10)}; apps: ${d.apps.join(", ")}).\n`);
45
62
  printMcp(d.mcp.url, d.key);
46
- return;
63
+ return { code: "signed_in", summary: "", data: {}, next: [] };
47
64
  }
48
- console.error("Timed out waiting for GitHub.");
49
- process.exit(1);
65
+ return fail("Timed out waiting for GitHub.", "login_timeout");
50
66
  }
51
67
  export async function keys() {
52
68
  const c = savedCreds();
package/dist/lumi.d.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { type Server } from "node:http";
2
+ import { type Driver, type Skin } from "./explore.js";
3
+ export type Mood = "idle" | "working" | "happy" | "worried";
4
+ export type Command = {
5
+ kind: "test-all";
6
+ } | {
7
+ kind: "test-flow";
8
+ flow: string;
9
+ } | {
10
+ kind: "bad-input";
11
+ flow: string;
12
+ } | {
13
+ kind: "stop";
14
+ } | {
15
+ kind: "replay";
16
+ n: number;
17
+ } | {
18
+ kind: "help";
19
+ } | {
20
+ kind: "unknown";
21
+ };
22
+ /** Reads a line of text as a command. No AI: a few patterns, case and spacing ignored. */
23
+ export declare function parseCommand(text: string): Command;
24
+ export declare const COMMAND_LIST: string[];
25
+ export type FeedItem = {
26
+ at: string;
27
+ text: string;
28
+ kind: "step" | "info" | "bug";
29
+ };
30
+ export type State = {
31
+ mood: Mood;
32
+ running: boolean;
33
+ label: string;
34
+ feed: FeedItem[];
35
+ findings: {
36
+ n: number;
37
+ kind: string;
38
+ message: string;
39
+ steps: string[];
40
+ }[];
41
+ coverage: {
42
+ screens: number;
43
+ controlsSeen: number;
44
+ controlsTried: number;
45
+ };
46
+ };
47
+ export type LumiOptions = {
48
+ routes?: string[];
49
+ maxDepth?: number;
50
+ maxSteps?: number;
51
+ seed?: number;
52
+ skin?: Skin;
53
+ app?: string;
54
+ settleMs?: number;
55
+ walkDelayMs?: number;
56
+ };
57
+ export declare class Lumi {
58
+ private driver;
59
+ private o;
60
+ private feed;
61
+ private known;
62
+ private screens;
63
+ private seen;
64
+ private tried;
65
+ private mood;
66
+ private running;
67
+ private label;
68
+ private stopFlag;
69
+ private listeners;
70
+ private job;
71
+ constructor(driver: Driver, o?: LumiOptions);
72
+ subscribe(fn: (s: State) => void): () => void;
73
+ state(): State;
74
+ /** Resolves when the current run or replay has ended. */
75
+ idle(): Promise<void>;
76
+ private emit;
77
+ private say;
78
+ /** Names you can use after `test` and `try`: configured routes, then the controls on the start screen. */
79
+ flows(): Promise<string[]>;
80
+ private resolve;
81
+ /** Handles one line from the command box. Returns the short reply at once; a run goes on in the background (see `idle`). */
82
+ command(text: string): Promise<string>;
83
+ private start;
84
+ private run;
85
+ /** Does the steps of a bug again, one by one, and says whether the screen is the same as when it was found. */
86
+ private replay;
87
+ }
88
+ /** "tide://cart" -> "cart", "/orders/open" -> "open". */
89
+ export declare function routeName(url: string): string;
90
+ /** The whole page: the phone frame with the live app picture in it, and the sidebar. */
91
+ export declare function lumiPage(frame: {
92
+ html: string;
93
+ css: string;
94
+ }, title: string): string;
95
+ export type LumiServerOptions = {
96
+ lumi: Lumi;
97
+ page: string;
98
+ shot: () => Promise<Buffer | null>;
99
+ port: number;
100
+ };
101
+ /** Local server for the page. It only answers requests whose Host is this machine's address, and only commands from this page. */
102
+ export declare function startLumiServer(o: LumiServerOptions): Promise<Server>;
103
+ /** Builds the app, opens it on Shipvo Phone in the browser with the Lumi sidebar. Runs until Ctrl-C. */
104
+ export declare function lumi(argv: string[]): Promise<number>;