@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/README.md +1 -1
- package/dist/adopt-bare.d.ts +39 -0
- package/dist/adopt-bare.js +236 -0
- package/dist/adopt.d.ts +26 -0
- package/dist/adopt.js +257 -0
- package/dist/autohalt.d.ts +5 -0
- package/dist/autohalt.js +84 -0
- package/dist/cli.js +119 -83
- package/dist/config.d.ts +10 -0
- package/dist/config.js +22 -9
- package/dist/doctor.d.ts +29 -0
- package/dist/doctor.js +210 -0
- package/dist/help.d.ts +8 -0
- package/dist/help.js +76 -0
- package/dist/hermes.d.ts +11 -0
- package/dist/hermes.js +41 -0
- package/dist/init.js +487 -20
- package/dist/login.js +5 -2
- package/dist/native.d.ts +3 -0
- package/dist/native.js +39 -0
- package/dist/policy.d.ts +18 -0
- package/dist/policy.js +33 -0
- package/dist/preview.d.ts +35 -0
- package/dist/preview.js +249 -0
- package/dist/promote.d.ts +9 -0
- package/dist/promote.js +126 -0
- package/dist/publish.d.ts +9 -0
- package/dist/publish.js +168 -33
- package/dist/sim.d.ts +27 -0
- package/dist/sim.js +138 -4
- package/dist/status.d.ts +16 -0
- package/dist/status.js +54 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +34 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +15 -0
- package/package.json +7 -4
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] =
|
|
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(
|
|
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: "
|
|
124
|
+
export const RELEASE = { headline: "Hello from ${name}.", note: "Edit src/release.ts and run \`shipvo publish\`." };
|
|
72
125
|
`);
|
|
73
|
-
|
|
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("
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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) => `//
|
|
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
|
|
198
|
-
expect(phone).toSee(
|
|
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
|
-
-
|
|
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
|
-
|
|
69
|
-
|
|
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`);
|
package/dist/native.d.ts
ADDED
|
@@ -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
|
+
}
|