@shipvo/cli 0.1.2 → 0.1.4

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.
package/dist/init.js CHANGED
@@ -5,14 +5,67 @@ 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
+ import { adopt, detectProject, isAdoptArgs, isBareApp, positional } from "./adopt.js";
8
9
  const HOSTED = "https://api.shipvo.dev";
9
10
  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
+ const TEMPLATES = ["blank", "content", "shop"];
12
+ const INIT_NEEDS_DIR = `shipvo: init needs a folder name.
13
+ Try: shipvo init myshop
14
+ 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
+ shipvo init [.] [--dry-run] [--yes] [--device] (adopt mode, inside an existing Expo or bare React Native app)
17
+
18
+ Adopt mode: run it inside an Expo app (package.json with expo, plus app.json or app.config.js/ts).
19
+ It shows a plan, then applies it after you confirm. It installs @shipvo/runtime, adds the config plugin
20
+ and a signing key, writes shipvo.json, adds sim/publish/rollback scripts, and adds a publish workflow if
21
+ 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
+
24
+ In a bare React Native app (ios/ and android/ committed, no Expo config plugins) it also writes the Shipvo
25
+ values into AndroidManifest.xml and Info.plist, and runs npx install-expo-modules@latest if Expo Modules are
26
+ missing. Only after you confirm.
27
+
28
+ --dry-run Print the plan and change nothing.
29
+ --yes Apply the plan without asking.
30
+ --device Also install @shipvo/device.
31
+
32
+ Creating a new app:
33
+
34
+ Creates <dir> with a ready app. Every option is optional.
35
+
36
+ --app-id <id> App id. Default: the folder name.
37
+ --name <name> Display name. Default: the app id in title case.
38
+ --bundle-id <id> iOS bundle id and Android package. Default: dev.shipvo.<app id>.
39
+ --control-plane <url> Where releases are published. Default: ${HOSTED}.
40
+ --public-key <b64> Signing public key to pin. Default: a new key is made for you.
41
+ --template <name> Starting app. One of: ${TEMPLATES.join(", ")}. Default: blank.
42
+ blank the starter with device API buttons.
43
+ content a list screen and a detail screen reading titles and text from src/data/articles.json.
44
+ shop a product list, a product page and a cart, reading products and prices from src/data/products.json.
45
+ --help Show this text.`;
10
46
  const title = (s) => s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
11
47
  export async function init(argv) {
12
- const [dirArg] = argv.filter((x) => !x.startsWith("--") && !argv[argv.indexOf(x) - 1]?.startsWith("--"));
48
+ const [dirArg] = positional(argv);
13
49
  const a = parse(argv);
50
+ if (argv.includes("--help") || argv.includes("-h")) {
51
+ console.log(INIT_HELP);
52
+ return;
53
+ }
54
+ if (isAdoptArgs(argv) && (dirArg === "." || detectProject(process.cwd()).isExpo || isBareApp(process.cwd()))) {
55
+ await adopt(argv);
56
+ return;
57
+ }
14
58
  if (!dirArg) {
15
- console.error("Usage: shipvo init <dir> [--app-id myshop] [--name \"My Shop\"] [--control-plane URL] [--public-key B64] [--bundle-id com.example.myshop]");
59
+ console.error(INIT_NEEDS_DIR);
60
+ process.exit(2);
61
+ }
62
+ if (a.template === "true") {
63
+ console.error(`shipvo: --template needs a name. Valid templates: ${TEMPLATES.join(", ")}.`);
64
+ process.exit(2);
65
+ }
66
+ const template = a.template ?? "blank";
67
+ if (!TEMPLATES.includes(template)) {
68
+ console.error(`shipvo: unknown template "${template}". Valid templates: ${TEMPLATES.join(", ")}.`);
16
69
  process.exit(2);
17
70
  }
18
71
  const dir = resolve(dirArg);
@@ -68,29 +121,51 @@ module.exports = withShipvoSim(getDefaultConfig(__dirname));
68
121
  w("index.ts", `import { registerRootComponent } from "expo";\nimport App from "./App";\nregisterRootComponent(App);\n`);
69
122
  w(".gitignore", `node_modules/\n.expo/\ndist/\ndist-ota/\ndist-web/\nsim-dist/\nweb-build/\n/ios\n/android\n.kotlin/\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n*.tsbuildinfo\n.env*.local\n.DS_Store\n# never commit the release signing seed\n.shipvo/\n`);
70
123
  w("src/release.ts", `// Edit and push: the publish workflow signs and ships this to every installed phone and to the simulator.
71
- export const RELEASE = { headline: "Release 1: hello from ${name}.", note: "Edit src/release.ts and run \`shipvo publish\`." };
124
+ export const RELEASE = { headline: "Hello from ${name}.", note: "Edit src/release.ts and run \`shipvo publish\`." };
72
125
  `);
73
- w("App.tsx", APP_TSX(name, appId));
126
+ if (template === "content") {
127
+ w("App.tsx", CONTENT_APP_TSX(name));
128
+ w("src/screens/List.tsx", CONTENT_LIST);
129
+ w("src/screens/Detail.tsx", CONTENT_DETAIL);
130
+ w("src/data/articles.json", CONTENT_DATA);
131
+ }
132
+ else if (template === "shop") {
133
+ w("App.tsx", SHOP_APP_TSX(name));
134
+ w("src/cart.tsx", SHOP_CART_STATE);
135
+ w("src/money.ts", SHOP_MONEY);
136
+ w("src/data/shop.json", SHOP_CONFIG);
137
+ w("src/screens/List.tsx", SHOP_LIST);
138
+ w("src/screens/Detail.tsx", SHOP_DETAIL);
139
+ w("src/screens/Cart.tsx", SHOP_CART);
140
+ w("src/data/products.json", SHOP_DATA);
141
+ }
142
+ else
143
+ w("App.tsx", APP_TSX(name, appId));
74
144
  w(".github/workflows/publish.yml", WORKFLOW);
75
145
  w(".github/workflows/test.yml", TEST_WORKFLOW);
76
- w("tests/app.test.ts", TEST_APP(name));
146
+ w(".github/workflows/preview.yml", PREVIEW_WORKFLOW);
147
+ w("tests/app.test.ts", template === "content" ? CONTENT_TEST : template === "shop" ? SHOP_TEST : TEST_APP(name));
77
148
  w("vitest.config.ts", VITEST_CONFIG);
78
- w("README.md", README(name, appId, controlPlane));
149
+ w("README.md", README(name, appId, controlPlane, template));
79
150
  console.log(`
80
151
  Created ${name} in ${dirArg}
81
152
 
82
- cd ${dirArg}
83
- npm install # or pnpm / bun
84
- npx playwright install chromium
85
- npm run sim # the app in the simulator on http://localhost:4545
86
- npx -y @shipvo/mcp # point Claude / Cursor / ChatGPT at it and open_app("${appId}")
153
+ ${seedNote}
154
+ Public key pinned in the shell: ${publicKey}
87
155
 
88
156
  Phones: npx expo run:android (needs Android Studio once). iOS: npx expo run:ios, then shipvo publish --platform all
89
157
  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
90
158
  (self-hosting instead? see docs/self-hosting.md)
159
+ AI agents: npx -y @shipvo/mcp points Claude / Cursor / ChatGPT at the simulator; then open_app("${appId}")
91
160
 
92
- ${seedNote}
93
- Public key pinned in the shell: ${publicKey}
161
+ Install first: npm install (or pnpm / bun), then npx playwright install chromium
162
+
163
+ Next steps:
164
+ 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
94
169
  `);
95
170
  }
96
171
  const APP_TSX = (name, appId) => `import { useEffect, useRef, useState } from "react";
@@ -111,7 +186,7 @@ export default function App() {
111
186
 
112
187
  // Over-the-air updates: tell the runtime the first screen rendered (so this release is kept), then poll while open.
113
188
  useEffect(() => {
114
- try { const h = Runtime.markHealthy(); add(\`Running release \${h.release}\`); } catch {}
189
+ try { const h = Runtime.markHealthy(); add(h.release ? \`Running release \${h.release}\` : "Running local build"); } catch {}
115
190
  const check = async () => {
116
191
  if (busy.current || !state?.baseUrl) return; busy.current = true;
117
192
  try { const r = await Runtime.checkForUpdate(true); if (r.status === "staged") add(\`Applied release \${r.release} (\${r.via ?? "full"})\`); }
@@ -140,7 +215,7 @@ export default function App() {
140
215
  <Btn label="Send a notification" onPress={notify} />
141
216
  <Text style={s.h2} accessibilityRole="header">Log</Text>
142
217
  {log.length === 0 ? <Text style={s.muted}>Nothing yet.</Text> : log.map((l, i) => <Text key={i} style={s.line}>{l}</Text>)}
143
- <Text style={s.muted}>App id ${appId} · shell {state?.shellVersion ?? "none"} · release {state?.runningRelease ?? 0}</Text>
218
+ <Text style={s.muted}>App id ${appId} · shell {state?.shellVersion ?? "none"} · {state?.runningRelease ? \`release \${state.runningRelease}\` : "local build"}</Text>
144
219
  </ScrollView>
145
220
  <ScanHost accent="#1F4E5F" />
146
221
  </View>
@@ -190,12 +265,16 @@ jobs:
190
265
  if: failure()
191
266
  with: { name: simulator-log, path: sim.log }
192
267
  `;
193
- const TEST_APP = (name) => `// Tests that run your app in the Shipvo simulator: a phone-sized, headless copy of the real app. No emulator, no device.
268
+ const TEST_APP = (name) => `// These tests check the starter screen that "shipvo init" made in App.tsx.
269
+ // When you replace App.tsx, they will fail: replace these tests with ones for your own screens.
270
+ // Nothing is wrong with your setup when they fail after that.
271
+ // Tests that run your app in the Shipvo simulator: a phone-sized, headless copy of the real app. No emulator, no device.
194
272
  // Run them with "npm test" (start the simulator first: npm run sim) and in CI (.github/workflows/test.yml).
195
273
  import { test, expect } from "@shipvo/test/vitest";
274
+ import { RELEASE } from "../src/release";
196
275
 
197
- test("the app opens and shows its release", async ({ phone }) => {
198
- expect(phone).toSee(/Release \\d+/);
276
+ test("the app opens and shows its headline", async ({ phone }) => {
277
+ expect(phone).toSee(RELEASE.headline);
199
278
  expect(phone.screen).not.toMatch(/something went wrong|unhandled|exception/i);
200
279
  });
201
280
 
@@ -235,6 +314,33 @@ test("notifications: a push can be opened from the shade", async ({ phone }) =>
235
314
  const VITEST_CONFIG = `import { defineConfig } from "vitest/config";
236
315
  export default defineConfig({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30_000 } });
237
316
  `;
317
+ const PREVIEW_WORKFLOW = `name: preview
318
+ # Publishes the pull request's web build to here.now (no account) and comments the link.
319
+ # The link is public and expires in 24 hours. Pull requests from forks are skipped.
320
+ on:
321
+ pull_request:
322
+ jobs:
323
+ preview:
324
+ if: \${{ github.event.pull_request.head.repo.full_name == github.repository }}
325
+ runs-on: ubuntu-latest
326
+ permissions:
327
+ pull-requests: write
328
+ steps:
329
+ - uses: actions/checkout@v4
330
+ - uses: actions/setup-node@v4
331
+ with: { node-version: 22 }
332
+ - run: npm ci
333
+ - name: Publish the preview
334
+ id: preview
335
+ run: echo "json=$(npx shipvo preview --json | tail -n 1)" >> "$GITHUB_OUTPUT"
336
+ - name: Comment the link
337
+ env:
338
+ GH_TOKEN: \${{ github.token }}
339
+ PREVIEW: \${{ steps.preview.outputs.json }}
340
+ run: |
341
+ url=$(echo "$PREVIEW" | jq -r .url)
342
+ gh pr comment "\${{ github.event.pull_request.number }}" --body "Preview: $url (public link, expires in 24 hours)"
343
+ `;
238
344
  const WORKFLOW = `name: publish
239
345
  on:
240
346
  push:
@@ -261,12 +367,373 @@ jobs:
261
367
  SHIPVO_SIGNING_SEED: \${{ secrets.SHIPVO_SIGNING_SEED }}
262
368
  run: npx shipvo publish | tee -a "$GITHUB_STEP_SUMMARY"
263
369
  `;
264
- const README = (name, appId, cp) => `# ${name}
370
+ const README = (name, appId, cp, template = "blank") => `# ${name}
265
371
 
266
372
  A [Shipvo](https://github.com/shipvo/shipvo) app: one React Native codebase that people install from the stores and AI agents operate in a simulator, kept current with signed over-the-air releases.
267
373
 
268
374
  - \`npm run sim\` opens it in the simulator; \`npx -y @shipvo/mcp\` lets Claude, Cursor or ChatGPT use it (\`open_app("${appId}")\`).
269
375
  - \`npx expo run:android\` builds the native shell (the trust root and control plane \`${cp}\` are pinned in it).
270
376
  - Push to \`main\`: the publish workflow signs the new JavaScript and every phone and simulator gets it.
271
- - \`src/release.ts\` is the first thing to edit; \`App.tsx\` shows the device API (camera, location, biometrics, notifications).
377
+ - ${template === "content" ? "`src/data/articles.json` is the first thing to edit: it holds the titles and text. `src/screens/` has the list and detail screens." : template === "shop" ? "`src/data/products.json` is the first thing to edit: it holds the product names, prices (in cents) and descriptions. `src/screens/` has the list, detail and cart screens." : "`src/release.ts` is the first thing to edit; `App.tsx` shows the device API (camera, location, biometrics, notifications)."}
378
+ `;
379
+ const CONTENT_DATA = JSON.stringify([
380
+ { id: "welcome", title: "Welcome", body: "This is the first article. Edit src/data/articles.json to change the titles and text." },
381
+ { id: "add-more", title: "Add more articles", body: "Each article has an id, a title and a body. Add an object to the list and it shows up on the first screen." },
382
+ { id: "ship-it", title: "Ship it", body: "Run shipvo publish and every installed phone gets your new articles over the air." },
383
+ ], null, 2) + "\n";
384
+ const CONTENT_APP_TSX = (name) => `import { useEffect, useRef, useState } from "react";
385
+ import { AppState, StyleSheet, View } from "react-native";
386
+ import { StatusBar } from "expo-status-bar";
387
+ import { Runtime } from "@shipvo/runtime";
388
+ import { List } from "./src/screens/List";
389
+ import { Detail } from "./src/screens/Detail";
390
+ import articles from "./src/data/articles.json";
391
+
392
+ export default function App() {
393
+ const [openId, setOpenId] = useState<string | null>(null);
394
+ // Tell the runtime the first screen rendered, so this release is kept.
395
+ // Then check for new releases now and while the app is open.
396
+ const busy = useRef(false);
397
+ useEffect(() => {
398
+ try { Runtime.markHealthy(); } catch {}
399
+ const check = async () => {
400
+ if (busy.current) return; busy.current = true;
401
+ try { await Runtime.checkForUpdate(true); } catch {}
402
+ finally { busy.current = false; }
403
+ };
404
+ check(); const id = setInterval(() => { if (AppState.currentState === "active") check(); }, 60000);
405
+ return () => clearInterval(id);
406
+ }, []);
407
+ const open = articles.find((a) => a.id === openId);
408
+ return (
409
+ <View style={s.root}>
410
+ <StatusBar style="dark" />
411
+ {open ? <Detail article={open} onBack={() => setOpenId(null)} /> : <List title="${name}" articles={articles} onOpen={setOpenId} />}
412
+ </View>
413
+ );
414
+ }
415
+
416
+ const s = StyleSheet.create({ root: { flex: 1, backgroundColor: "#F6F8F7" } });
417
+ `;
418
+ const CONTENT_LIST = `import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
419
+
420
+ export type Article = { id: string; title: string; body: string };
421
+
422
+ export function List({ title, articles, onOpen }: { title: string; articles: Article[]; onOpen: (id: string) => void }) {
423
+ return (
424
+ <ScrollView contentContainerStyle={s.page}>
425
+ <Text style={s.h1} accessibilityRole="header">{title}</Text>
426
+ {articles.map((a) => (
427
+ <Pressable key={a.id} accessibilityRole="button" accessibilityLabel={a.title} onPress={() => onOpen(a.id)} style={s.row}>
428
+ <Text style={s.rowText}>{a.title}</Text>
429
+ </Pressable>
430
+ ))}
431
+ </ScrollView>
432
+ );
433
+ }
434
+
435
+ const s = StyleSheet.create({
436
+ page: { padding: 20, paddingTop: 56, gap: 12 },
437
+ h1: { fontSize: 28, fontWeight: "800", color: "#111" },
438
+ row: { minHeight: 48, borderRadius: 10, borderWidth: 1, borderColor: "#CBD5D1", justifyContent: "center", paddingHorizontal: 16, backgroundColor: "#fff" },
439
+ rowText: { fontSize: 16, fontWeight: "700", color: "#111" },
440
+ });
441
+ `;
442
+ const CONTENT_DETAIL = `import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
443
+ import type { Article } from "./List";
444
+
445
+ export function Detail({ article, onBack }: { article: Article; onBack: () => void }) {
446
+ return (
447
+ <ScrollView contentContainerStyle={s.page}>
448
+ <Pressable accessibilityRole="button" accessibilityLabel="Back" onPress={onBack} style={s.back}><Text style={s.backText}>Back</Text></Pressable>
449
+ <Text style={s.h1} accessibilityRole="header">{article.title}</Text>
450
+ <Text style={s.body}>{article.body}</Text>
451
+ </ScrollView>
452
+ );
453
+ }
454
+
455
+ const s = StyleSheet.create({
456
+ page: { padding: 20, paddingTop: 56, gap: 12 },
457
+ back: { minHeight: 48, justifyContent: "center" }, backText: { fontSize: 16, fontWeight: "700", color: "#1F4E5F" },
458
+ h1: { fontSize: 24, fontWeight: "800", color: "#111" },
459
+ body: { fontSize: 16, lineHeight: 24, color: "#111" },
460
+ });
461
+ `;
462
+ const CONTENT_TEST = `// Runs the app in the Shipvo simulator. Start it first: npm run sim.
463
+ import { test, expect } from "@shipvo/test/vitest";
464
+ import articles from "../src/data/articles.json";
465
+
466
+ test("the list shows every title", async ({ phone }) => {
467
+ for (const a of articles) expect(phone).toSee(a.title);
468
+ expect(phone.screen).not.toMatch(/something went wrong|unhandled|exception/i);
469
+ });
470
+
471
+ test("opening a title shows its text, and Back returns to the list", async ({ phone }) => {
472
+ await phone.tap(articles[0].title);
473
+ await phone.waitFor(articles[0].body);
474
+ await phone.tap("Back");
475
+ await phone.waitFor(articles[1].title);
476
+ });
477
+ `;
478
+ const SHOP_DATA = JSON.stringify([
479
+ { id: "canvas-tote", name: "Canvas tote", price: 1800, description: "A sturdy cotton bag with a long strap." },
480
+ { id: "enamel-mug", name: "Enamel mug", price: 1200, description: "A light mug that keeps coffee warm on a walk." },
481
+ { id: "pocket-notebook", name: "Pocket notebook", price: 650, description: "Sixty plain pages and a soft cover." },
482
+ { id: "wool-beanie", name: "Wool beanie", price: 2400, description: "A warm hat in undyed wool." },
483
+ { id: "steel-bottle", name: "Steel bottle", price: 2900, description: "A one litre bottle with a screw cap." },
484
+ ], null, 2) + "\n";
485
+ const SHOP_CONFIG = JSON.stringify({ currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
486
+ 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
+ // 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
+ import shop from "./data/shop.json";
489
+
490
+ export const formatPrice = (minor: number) => {
491
+ const { currency, position, decimals, locale } = shop as { currency: string; position?: string; decimals?: number; locale?: string };
492
+ const d = decimals ?? 2;
493
+ const n = (minor / 10 ** d).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d });
494
+ // Some locales use a narrow no-break space between digit groups. Plain spaces read the same and match everywhere.
495
+ const num = n.replace(/[\u00a0\u202f]/g, " ");
496
+ return position === "after" ? num + " " + currency : currency + num;
497
+ };
498
+
499
+ // 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
+ // Prices are minor units, so divide by 10 ** decimals. "currency" is the ISO code from shop.json.
501
+ export const chargeRequest = (minor: number) => {
502
+ const { currency, code, decimals } = shop as { currency: string; code?: string; decimals?: number };
503
+ return { amount: minor / 10 ** (decimals ?? 2), currency: code ?? currency };
504
+ };
505
+ `;
506
+ const SHOP_CART_STATE = `import { createContext, useContext, useState, type ReactNode } from "react";
507
+ import products from "./data/products.json";
508
+
509
+ export type Line = { id: string; qty: number };
510
+ type Cart = { lines: Line[]; add: (id: string) => void; remove: (id: string) => void; clear: () => void; totalCents: number };
511
+
512
+ const Ctx = createContext<Cart | null>(null);
513
+
514
+ export function CartProvider({ children }: { children: ReactNode }) {
515
+ const [lines, setLines] = useState<Line[]>([]);
516
+ const add = (id: string) => setLines((l) => l.some((x) => x.id === id) ? l.map((x) => x.id === id ? { ...x, qty: x.qty + 1 } : x) : [...l, { id, qty: 1 }]);
517
+ const remove = (id: string) => setLines((l) => l.filter((x) => x.id !== id));
518
+ const clear = () => setLines([]);
519
+ const totalCents = lines.reduce((sum, x) => sum + x.qty * (products.find((p) => p.id === x.id)?.price ?? 0), 0);
520
+ return <Ctx.Provider value={{ lines, add, remove, clear, totalCents }}>{children}</Ctx.Provider>;
521
+ }
522
+
523
+ export function useCart() {
524
+ const c = useContext(Ctx);
525
+ if (!c) throw new Error("useCart needs a CartProvider");
526
+ return c;
527
+ }
528
+ `;
529
+ const SHOP_APP_TSX = (name) => `import { useEffect, useRef, useState } from "react";
530
+ import { AppState, StyleSheet, View } from "react-native";
531
+ import { StatusBar } from "expo-status-bar";
532
+ import { Runtime } from "@shipvo/runtime";
533
+ import { CartProvider } from "./src/cart";
534
+ import { List } from "./src/screens/List";
535
+ import { Detail } from "./src/screens/Detail";
536
+ import { Cart } from "./src/screens/Cart";
537
+ import products from "./src/data/products.json";
538
+
539
+ type Route = { name: "list" } | { name: "detail"; id: string } | { name: "cart" };
540
+
541
+ export default function App() {
542
+ const [route, setRoute] = useState<Route>({ name: "list" });
543
+ // Tell the runtime the first screen rendered, so this release is kept.
544
+ // Then check for new releases now and while the app is open.
545
+ const busy = useRef(false);
546
+ useEffect(() => {
547
+ try { Runtime.markHealthy(); } catch {}
548
+ const check = async () => {
549
+ if (busy.current) return; busy.current = true;
550
+ try { await Runtime.checkForUpdate(true); } catch {}
551
+ finally { busy.current = false; }
552
+ };
553
+ check(); const id = setInterval(() => { if (AppState.currentState === "active") check(); }, 60000);
554
+ return () => clearInterval(id);
555
+ }, []);
556
+ const product = route.name === "detail" ? products.find((p) => p.id === route.id) : undefined;
557
+ const toList = () => setRoute({ name: "list" });
558
+ const toCart = () => setRoute({ name: "cart" });
559
+ return (
560
+ <CartProvider>
561
+ <View style={s.root}>
562
+ <StatusBar style="dark" />
563
+ {route.name === "cart" ? <Cart onBack={toList} />
564
+ : product ? <Detail product={product} onBack={toList} onCart={toCart} />
565
+ : <List title="${name}" products={products} onOpen={(id) => setRoute({ name: "detail", id })} onCart={toCart} />}
566
+ </View>
567
+ </CartProvider>
568
+ );
569
+ }
570
+
571
+ const s = StyleSheet.create({ root: { flex: 1, backgroundColor: "#F6F8F7" } });
572
+ `;
573
+ const SHOP_LIST = `import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
574
+ import { formatPrice } from "../money";
575
+
576
+ export type Product = { id: string; name: string; price: number; description: string };
577
+
578
+ export function List({ title, products, onOpen, onCart }: { title: string; products: Product[]; onOpen: (id: string) => void; onCart: () => void }) {
579
+ return (
580
+ <ScrollView contentContainerStyle={s.page}>
581
+ <View style={s.head}>
582
+ <Text style={s.h1} accessibilityRole="header">{title}</Text>
583
+ <Pressable accessibilityRole="button" accessibilityLabel="Cart" onPress={onCart} style={s.cart}><Text style={s.cartText}>Cart</Text></Pressable>
584
+ </View>
585
+ {products.map((p) => (
586
+ <Pressable key={p.id} accessibilityRole="button" accessibilityLabel={p.name} onPress={() => onOpen(p.id)} style={s.row}>
587
+ <Text style={s.rowText}>{p.name}</Text>
588
+ <Text style={s.price}>{formatPrice(p.price)}</Text>
589
+ </Pressable>
590
+ ))}
591
+ </ScrollView>
592
+ );
593
+ }
594
+
595
+ const s = StyleSheet.create({
596
+ page: { padding: 20, paddingTop: 56, gap: 12 },
597
+ head: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
598
+ h1: { fontSize: 28, fontWeight: "800", color: "#111" },
599
+ cart: { minHeight: 48, justifyContent: "center", paddingHorizontal: 8 }, cartText: { fontSize: 16, fontWeight: "700", color: "#1F4E5F" },
600
+ row: { minHeight: 48, borderRadius: 10, borderWidth: 1, borderColor: "#CBD5D1", flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 16, backgroundColor: "#fff" },
601
+ rowText: { fontSize: 16, fontWeight: "700", color: "#111" },
602
+ price: { fontSize: 16, color: "#111" },
603
+ });
604
+ `;
605
+ const SHOP_DETAIL = `import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
606
+ import { useCart } from "../cart";
607
+ import { formatPrice } from "../money";
608
+ import type { Product } from "./List";
609
+
610
+ export function Detail({ product, onBack, onCart }: { product: Product; onBack: () => void; onCart: () => void }) {
611
+ const { add } = useCart();
612
+ return (
613
+ <ScrollView contentContainerStyle={s.page}>
614
+ <View style={s.head}>
615
+ <Pressable accessibilityRole="button" accessibilityLabel="Back" onPress={onBack} style={s.link}><Text style={s.linkText}>Back</Text></Pressable>
616
+ <Pressable accessibilityRole="button" accessibilityLabel="Cart" onPress={onCart} style={s.link}><Text style={s.linkText}>Cart</Text></Pressable>
617
+ </View>
618
+ <Text style={s.h1} accessibilityRole="header">{product.name}</Text>
619
+ <Text style={s.price}>{formatPrice(product.price)}</Text>
620
+ <Text style={s.body}>{product.description}</Text>
621
+ <Pressable accessibilityRole="button" accessibilityLabel="Add to cart" onPress={() => add(product.id)} style={s.add}><Text style={s.addText}>Add to cart</Text></Pressable>
622
+ </ScrollView>
623
+ );
624
+ }
625
+
626
+ const s = StyleSheet.create({
627
+ page: { padding: 20, paddingTop: 56, gap: 12 },
628
+ head: { flexDirection: "row", justifyContent: "space-between" },
629
+ link: { minHeight: 48, justifyContent: "center" }, linkText: { fontSize: 16, fontWeight: "700", color: "#1F4E5F" },
630
+ h1: { fontSize: 24, fontWeight: "800", color: "#111" },
631
+ price: { fontSize: 18, fontWeight: "700", color: "#111" },
632
+ body: { fontSize: 16, lineHeight: 24, color: "#111" },
633
+ add: { minHeight: 48, borderRadius: 10, backgroundColor: "#1F4E5F", alignItems: "center", justifyContent: "center" }, addText: { fontSize: 16, fontWeight: "700", color: "#fff" },
634
+ });
635
+ `;
636
+ const SHOP_CART = `import { useState } from "react";
637
+ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
638
+ import { device } from "@shipvo/device";
639
+ import { useCart } from "../cart";
640
+ import { chargeRequest, formatPrice } from "../money";
641
+ import products from "../data/products.json";
642
+
643
+ export function Cart({ onBack }: { onBack: () => void }) {
644
+ const { lines, remove, clear, totalCents } = useCart();
645
+ const [status, setStatus] = useState<string | null>(null);
646
+ const [busy, setBusy] = useState(false);
647
+ // Replace "to" with your own mobile money number. The PIN here is the sandbox PIN; ask the customer for theirs in a real app.
648
+ const pay = async () => {
649
+ if (busy) return;
650
+ setBusy(true);
651
+ setStatus("Paying...");
652
+ 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);
655
+ } catch (e: any) { setStatus("Payment failed: " + String(e?.message ?? e)); }
656
+ finally { setBusy(false); }
657
+ };
658
+ return (
659
+ <ScrollView contentContainerStyle={s.page}>
660
+ <Pressable accessibilityRole="button" accessibilityLabel="Back" onPress={onBack} style={s.back}><Text style={s.backText}>Back</Text></Pressable>
661
+ <Text style={s.h1} accessibilityRole="header">Cart</Text>
662
+ {lines.length === 0 ? <Text style={s.body}>Your cart is empty</Text> : null}
663
+ {lines.map((l) => {
664
+ const p = products.find((x) => x.id === l.id);
665
+ if (!p) return null;
666
+ return (
667
+ <View key={l.id} style={s.row}>
668
+ <Text style={s.body}>{p.name} x {l.qty}</Text>
669
+ <Pressable accessibilityRole="button" accessibilityLabel={"Remove " + p.name} onPress={() => remove(l.id)} style={s.remove}><Text style={s.backText}>Remove</Text></Pressable>
670
+ </View>
671
+ );
672
+ })}
673
+ <Text style={s.total}>{"Total: " + formatPrice(totalCents)}</Text>
674
+ {lines.length > 0 ? <Pressable accessibilityRole="button" accessibilityLabel="Pay" accessibilityState={{ disabled: busy }} onPress={pay} style={s.pay}><Text style={s.payText}>Pay</Text></Pressable> : null}
675
+ {status ? <Text style={s.body} accessibilityRole="alert">{status}</Text> : null}
676
+ </ScrollView>
677
+ );
678
+ }
679
+
680
+ const s = StyleSheet.create({
681
+ page: { padding: 20, paddingTop: 56, gap: 12 },
682
+ back: { minHeight: 48, justifyContent: "center" }, backText: { fontSize: 16, fontWeight: "700", color: "#1F4E5F" },
683
+ h1: { fontSize: 24, fontWeight: "800", color: "#111" },
684
+ row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
685
+ remove: { minHeight: 48, justifyContent: "center" },
686
+ body: { fontSize: 16, color: "#111" },
687
+ total: { fontSize: 20, fontWeight: "800", color: "#111" },
688
+ pay: { minHeight: 48, justifyContent: "center", alignItems: "center", borderRadius: 8, backgroundColor: "#1F4E5F" }, payText: { fontSize: 16, fontWeight: "700", color: "#fff" },
689
+ });
690
+ `;
691
+ const SHOP_TEST = `// Runs the app in the Shipvo simulator. Start it first: npm run sim.
692
+ // Names and prices come from src/data/products.json, so editing that file does not break this test.
693
+ // Keep product names unique and not a substring of each other: tap(name) fails if two elements match.
694
+ // The total is computed with formatPrice from the app, so this test does not check that function.
695
+ import { test, expect } from "@shipvo/test/vitest";
696
+ import products from "../src/data/products.json";
697
+ import { formatPrice } from "../src/money";
698
+
699
+ test("the list shows every product", async ({ phone }) => {
700
+ for (const p of products) expect(phone).toSee(p.name);
701
+ expect(phone.screen).not.toMatch(/something went wrong|unhandled|exception/i);
702
+ });
703
+
704
+ test("adding two products puts their total in the cart, and Remove takes one out", async ({ phone }) => {
705
+ const [a, b] = products;
706
+ await phone.tap(a.name);
707
+ await phone.waitFor(a.description);
708
+ await phone.tap("Add to cart");
709
+ await phone.tap("Back");
710
+ await phone.waitFor(b.name);
711
+ await phone.tap(b.name);
712
+ await phone.tap("Add to cart");
713
+ await phone.tap("Cart");
714
+ await phone.waitFor("Total: " + formatPrice(a.price + b.price));
715
+ await phone.tap("Remove " + b.name);
716
+ await phone.waitFor("Total: " + formatPrice(a.price));
717
+ });
718
+
719
+ test("paying with mobile money places the order and empties the cart", async ({ phone }) => {
720
+ await phone.device.set({ payment: { nextResult: "approve", pin: "1234" } });
721
+ await phone.tap(products[0].name);
722
+ await phone.tap("Add to cart");
723
+ await phone.tap("Cart");
724
+ await phone.tap("Pay");
725
+ await phone.waitFor("Order placed");
726
+ expect(phone).toSee("Your cart is empty");
727
+ });
728
+
729
+ test("a declined payment says why and keeps the cart", async ({ phone }) => {
730
+ await phone.device.set({ payment: { nextResult: "decline", pin: "1234" } });
731
+ await phone.tap(products[0].name);
732
+ await phone.tap("Add to cart");
733
+ await phone.tap("Cart");
734
+ await phone.tap("Pay");
735
+ await phone.waitFor("Payment failed: declined");
736
+ expect(phone).toSee("Total: " + formatPrice(products[0].price));
737
+ expect(phone.sees("Order placed")).toBe(false);
738
+ });
272
739
  `;
package/dist/login.js CHANGED
@@ -65,11 +65,14 @@ export async function keys() {
65
65
  export function mcp() {
66
66
  const c = savedCreds();
67
67
  if (!c) {
68
- console.error("Not signed in. Run `shipvo login`.");
69
- process.exit(1);
68
+ printLocalMcp();
69
+ console.log("\nTo let agents open your published app from anywhere, run `shipvo login` for a hosted config.");
70
+ return;
70
71
  }
71
72
  printMcp(c.mcp_url, c.key);
72
73
  }
74
+ const LOCAL_MCP = `Local simulator (no login needed). Run \`npm run sim\` first:\n${JSON.stringify({ mcpServers: { shipvo: { command: "npx", args: ["-y", "@shipvo/mcp"] } } }, null, 2)}`;
75
+ function printLocalMcp() { console.log(LOCAL_MCP); }
73
76
  function printMcp(url, key) {
74
77
  console.log(`Claude Desktop / Cursor / Claude Code (remote):\n${JSON.stringify({ mcpServers: { shipvo: { url, headers: { Authorization: `Bearer ${key}` } } } }, null, 2)}\n`);
75
78
  console.log(`ChatGPT (custom MCP connector, cannot send headers): ${url}/${key}\n`);
@@ -0,0 +1,3 @@
1
+ /** Package name to semver range, from `dependencies` of the project's package.json. Undefined when none are native. */
2
+ /** Native dependencies whose range was not recorded are pushed to `skipped` as `name@spec`. */
3
+ export declare function requiredNativeModules(root?: string, skipped?: string[]): Record<string, string> | undefined;
package/dist/native.js ADDED
@@ -0,0 +1,39 @@
1
+ // Find the native modules an app needs: dependencies in package.json whose installed package has native code.
2
+ // A package counts as native if it ships an android/ or ios/ folder, a podspec, or an Expo/RN native config file.
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ const NATIVE_FILES = ["expo-module.config.json", "react-native.config.js"];
6
+ const RANGE = /^[\^~<>=v\d xX*|. -]+$/; // plain numeric ranges only; workspace:, file:, git, tag and pre-release specs are skipped and reported
7
+ function hasNativeCode(dir) {
8
+ if (NATIVE_FILES.some((f) => existsSync(join(dir, f))))
9
+ return true;
10
+ if (existsSync(join(dir, "android")) || existsSync(join(dir, "ios")))
11
+ return true;
12
+ try {
13
+ return readdirSync(dir).some((f) => f.endsWith(".podspec"));
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** Package name to semver range, from `dependencies` of the project's package.json. Undefined when none are native. */
20
+ /** Native dependencies whose range was not recorded are pushed to `skipped` as `name@spec`. */
21
+ export function requiredNativeModules(root = ".", skipped = []) {
22
+ let pkg;
23
+ try {
24
+ pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ const out = {};
30
+ for (const [name, range] of Object.entries(pkg.dependencies ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
31
+ if (!hasNativeCode(join(root, "node_modules", name)))
32
+ continue;
33
+ if (RANGE.test(range))
34
+ out[name] = range;
35
+ else
36
+ skipped.push(`${name}@${range}`);
37
+ }
38
+ return Object.keys(out).length ? out : undefined;
39
+ }