@shipvo/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +15 -0
  4. package/dist/agents.js +68 -9
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +28 -11
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +8 -0
  20. package/dist/ci.js +64 -6
  21. package/dist/cli.js +23 -1
  22. package/dist/config.d.ts +13 -0
  23. package/dist/config.js +43 -3
  24. package/dist/doctor.js +63 -7
  25. package/dist/ensure-playwright.d.ts +4 -0
  26. package/dist/ensure-playwright.js +7 -2
  27. package/dist/explore-issues.d.ts +12 -0
  28. package/dist/explore-issues.js +50 -0
  29. package/dist/explore.d.ts +83 -7
  30. package/dist/explore.js +326 -77
  31. package/dist/flows.d.ts +31 -0
  32. package/dist/flows.js +264 -0
  33. package/dist/help.d.ts +2 -2
  34. package/dist/help.js +19 -10
  35. package/dist/hermes.d.ts +10 -0
  36. package/dist/hermes.js +50 -0
  37. package/dist/init.d.ts +1 -0
  38. package/dist/init.js +41 -6
  39. package/dist/json-out.d.ts +53 -0
  40. package/dist/json-out.js +91 -0
  41. package/dist/keys-json.d.ts +1 -0
  42. package/dist/keys-json.js +71 -0
  43. package/dist/login.js +27 -11
  44. package/dist/lumi.d.ts +104 -0
  45. package/dist/lumi.js +364 -0
  46. package/dist/migrate.d.ts +27 -0
  47. package/dist/migrate.js +521 -0
  48. package/dist/preview.d.ts +1 -0
  49. package/dist/preview.js +4 -2
  50. package/dist/promote.d.ts +1 -1
  51. package/dist/promote.js +38 -16
  52. package/dist/publish.d.ts +14 -2
  53. package/dist/publish.js +126 -62
  54. package/dist/qr.d.ts +9 -0
  55. package/dist/qr.js +213 -0
  56. package/dist/report-md.d.ts +1 -0
  57. package/dist/report-md.js +1 -1
  58. package/dist/rotation.d.ts +57 -0
  59. package/dist/rotation.js +157 -0
  60. package/dist/run-bridge.js +356 -2
  61. package/dist/run-net.d.ts +39 -0
  62. package/dist/run-net.js +266 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +101 -17
  65. package/dist/signing.js +13 -0
  66. package/dist/sim.js +7 -3
  67. package/dist/smoke.d.ts +28 -0
  68. package/dist/smoke.js +117 -0
  69. package/dist/spawn-bin.d.ts +11 -0
  70. package/dist/spawn-bin.js +21 -0
  71. package/dist/status.d.ts +26 -4
  72. package/dist/status.js +69 -5
  73. package/dist/templates/github-action/shipvo-check.yml +18 -0
  74. package/dist/test.d.ts +1 -1
  75. package/dist/test.js +81 -18
  76. package/package.json +4 -3
  77. package/skills/shipvo-cli/SKILL.md +31 -0
@@ -0,0 +1,521 @@
1
+ // `shipvo migrate --plan`: read-only plan for switching an Expo app from EAS Update (expo-updates) to Shipvo.
2
+ // It reads package.json and the app config and prints steps. It writes nothing.
3
+ // `--apply` makes the safe edits (backups first). See applyEdits.
4
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { detectProject } from "./adopt.js";
8
+ import { CliFailure, runJson, UsageError } from "./json-out.js";
9
+ const readJson = (p) => { try {
10
+ return JSON.parse(readFileSync(p, "utf8"));
11
+ }
12
+ catch {
13
+ return null;
14
+ } };
15
+ const DOC = "docs/migrate-from-eas-update.md";
16
+ const SKIP_DIRS = new Set(["node_modules", ".git", "Pods", "build", "dist", "dist-ota", "dist-web", ".shipvo", ".gradle", "DerivedData", ".expo"]);
17
+ const CODE_EXT = /\.(?:[cm]?[jt]sx?)$/;
18
+ const CI_FILE = /(?:\.ya?ml|\.sh|Fastfile|Makefile)$/;
19
+ function walk(dir, rel = "", depth = 0, out = []) {
20
+ if (depth > 8 || out.length > 5000)
21
+ return out;
22
+ let names;
23
+ try {
24
+ names = readdirSync(join(dir, rel)).sort();
25
+ }
26
+ catch {
27
+ return out;
28
+ }
29
+ for (const n of names) {
30
+ if (SKIP_DIRS.has(n))
31
+ continue;
32
+ const r = rel ? `${rel}/${n}` : n;
33
+ let st;
34
+ try {
35
+ st = statSync(join(dir, r));
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ if (st.isDirectory())
41
+ walk(dir, r, depth + 1, out);
42
+ else if (st.size < 1_000_000)
43
+ out.push(r);
44
+ }
45
+ return out;
46
+ }
47
+ /** Finds CodePush entries with file and line. Reads only. */
48
+ function scanCodePush(dir) {
49
+ const found = [];
50
+ const add = (kind, file, line, entry) => found.push({ kind, where: `${file}:${line}`, entry, file, line });
51
+ for (const file of walk(dir)) {
52
+ const base = file.split("/").pop();
53
+ const isPkg = file === "package.json";
54
+ const isCode = CODE_EXT.test(file);
55
+ const isNative = base === "Info.plist" || base === "strings.xml";
56
+ const isCi = CI_FILE.test(base) || base === ".gitlab-ci.yml";
57
+ if (!isPkg && !isCode && !isNative && !isCi)
58
+ continue;
59
+ let lines;
60
+ try {
61
+ lines = readFileSync(join(dir, file), "utf8").split("\n");
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ lines.forEach((text, i) => {
67
+ const n = i + 1;
68
+ const t = text.trim();
69
+ if (isPkg) {
70
+ const d = /"((?:@revopush\/)?react-native-code-push)"\s*:\s*"([^"]*)"/.exec(text);
71
+ if (d)
72
+ add("dependency", file, n, `${d[1]}@${d[2]}`);
73
+ }
74
+ if (isCode && !isPkg) {
75
+ if (/\bcodePush\s*\(/.test(text) && !/^\s*(\/\/|\*)/.test(text))
76
+ add("wrapper", file, n, t);
77
+ else if (/\bCodePush\.(sync|checkForUpdate|getUpdateMetadata|notifyAppReady|restartApp|allowRestart|disallowRestart)\b/.test(text) && !/^\s*(\/\/|\*)/.test(text))
78
+ add("sync", file, n, t);
79
+ }
80
+ if (isNative) {
81
+ if (/CodePushDeploymentKey/.test(text))
82
+ add("deployment-key", file, n, "CodePushDeploymentKey");
83
+ if (/CodePushPublicKey/.test(text))
84
+ add("public-key", file, n, "CodePushPublicKey");
85
+ }
86
+ if ((isPkg || isCi) && /appcenter\s+codepush/.test(text))
87
+ add("command", file, n, t.replace(/^"[^"]*":\s*/, "").replace(/,$/, "").replace(/^"|"$/g, ""));
88
+ });
89
+ }
90
+ return found;
91
+ }
92
+ function codePushSteps(found) {
93
+ const has = (k) => found.filter((f) => f.kind === k);
94
+ const at = (k) => [...new Set(has(k).map((f) => f.where))].join(", ");
95
+ const steps = ["Commit your work. Installed apps keep using CodePush until a new store build ships."];
96
+ if (has("wrapper").length || has("sync").length)
97
+ steps.push(`Replace the codePush() wrapper and CodePush.sync calls (${[at("wrapper"), at("sync")].filter(Boolean).join(", ")}) with @shipvo/runtime: call Runtime.markHealthy() once the first screen renders and Runtime.checkForUpdate(true) to look for a release.`);
98
+ if (has("deployment-key").length || has("public-key").length)
99
+ steps.push(`Remove the native keys (${[at("deployment-key"), at("public-key")].filter(Boolean).join(", ")}). Shipvo pins one control plane URL and one public key in the app config, not a per-environment deployment key.`);
100
+ steps.push("Remove react-native-code-push from package.json" + (has("dependency").length ? ` (${at("dependency")})` : "") + " and reinstall, so no build has two update mechanisms.");
101
+ steps.push("Add @shipvo/runtime and @shipvo/device, and @shipvo/cli as a dev dependency.");
102
+ steps.push("Create the Shipvo config: run shipvo init in a scratch folder and copy shipvo.json and the @shipvo/runtime plugin entry into your app config. Back up the signing seed (shipvo keys export); if you lose it, installed apps will not accept another update.");
103
+ if (has("command").length)
104
+ steps.push(`Replace the appcenter codepush commands (${at("command")}) with shipvo publish, and appcenter codepush rollback with shipvo rollback --to <release>. --rollout <n> has no direct equivalent: Shipvo releases to cohort bands (--bands b0,b1,b2), then shipvo promote widens a release.`);
105
+ else
106
+ steps.push("Publish with shipvo publish --bands b0, then shipvo promote. CodePush --rollout <n> has no direct equivalent: Shipvo uses cohort bands.");
107
+ steps.push("Run shipvo doctor and shipvo publish --dry-run.");
108
+ steps.push("Build and submit one new store build. Only this build uses Shipvo; older binaries keep using CodePush.");
109
+ steps.push("Run shipvo publish for the first Shipvo release once the new build is on devices.");
110
+ return steps;
111
+ }
112
+ /** Reads the app and builds the plan. Changes nothing. */
113
+ export function buildPlan(dir) {
114
+ const p = detectProject(dir);
115
+ const pkg = readJson(join(dir, "package.json"));
116
+ const found = [];
117
+ for (const sec of ["dependencies", "devDependencies"]) {
118
+ for (const n of ["expo-updates", "react-native-code-push", "@revopush/react-native-code-push"])
119
+ if (pkg?.[sec]?.[n])
120
+ found.push({ kind: "dependency", where: `package.json ${sec}`, entry: `${n}@${pkg[sec][n]}` });
121
+ }
122
+ let configNote = false;
123
+ if (p.configKind === "app.json") {
124
+ const raw = readJson(join(dir, "app.json"));
125
+ const expo = raw?.expo ?? raw;
126
+ for (const pl of expo?.plugins ?? [])
127
+ if ((Array.isArray(pl) ? pl[0] : pl) === "expo-updates")
128
+ found.push({ kind: "plugin", where: "app.json plugins", entry: "expo-updates" });
129
+ for (const k of ["updates", "runtimeVersion"])
130
+ if (expo && k in expo)
131
+ found.push({ kind: "config-key", where: "app.json", entry: `${k}: ${JSON.stringify(expo[k])}` });
132
+ for (const os of ["ios", "android"])
133
+ if (expo?.[os] && "runtimeVersion" in expo[os])
134
+ found.push({ kind: "config-key", where: "app.json", entry: `${os}.runtimeVersion: ${JSON.stringify(expo[os].runtimeVersion)}` });
135
+ }
136
+ else if (p.configFile)
137
+ configNote = true;
138
+ const system = p.expoUpdates ? "expo-updates" : p.codePush ? "codepush" : null;
139
+ if (!system)
140
+ return { system, found, steps: [] };
141
+ const steps = [];
142
+ if (system === "codepush") {
143
+ const cp = [...found.filter((f) => !/react-native-code-push/.test(f.entry)), ...scanCodePush(dir)];
144
+ return { system, found: cp, steps: codePushSteps(cp) };
145
+ }
146
+ steps.push("Commit your work. Installed apps keep using EAS Update until a new store build ships, so do not stop publishing to EAS yet.");
147
+ if (configNote)
148
+ steps.push(`Open ${p.configFile} and look for the same entries by hand: the expo-updates plugin and the updates and runtimeVersion keys. This plan cannot read a code config.`);
149
+ steps.push("Remove expo-updates from package.json (" + p.packageManager + " remove expo-updates).");
150
+ steps.push("Remove the expo-updates entry from plugins, and the updates and runtimeVersion keys, from the app config.");
151
+ steps.push("Remove calls to the expo-updates API (for example Updates.checkForUpdateAsync) from your code.");
152
+ steps.push("Run shipvo init. It adds @shipvo/runtime, the config plugin, a signing key, shipvo.json, the sim/publish/rollback scripts and a publish workflow. It never overwrites a file.");
153
+ steps.push("Back up the signing seed (shipvo keys export). If you lose it, installed apps will not accept another update.");
154
+ steps.push("Build and submit a new store build. Only this build uses Shipvo; older binaries keep using EAS Update.");
155
+ steps.push("Run shipvo publish --dry-run, then shipvo publish for the first Shipvo release once the new build is on devices.");
156
+ return { system, found, steps };
157
+ }
158
+ const BACKUP = join(".shipvo", "migrate-backup");
159
+ const isPlugin = (pl) => (Array.isArray(pl) ? pl[0] : pl) === "expo-updates";
160
+ const indentOf = (raw) => (/^[ \t]+(?=")/m.exec(raw)?.[0] ?? " ");
161
+ /** Edits one JSON file in memory. Returns the new text and what was removed, or null when nothing applies. */
162
+ function editJson(raw, edit) {
163
+ const o = JSON.parse(raw);
164
+ const removed = edit(o);
165
+ return removed.length ? { text: JSON.stringify(o, null, indentOf(raw)) + (raw.endsWith("\n") ? "\n" : ""), removed } : null;
166
+ }
167
+ /** The safe edits: expo-updates dependency and plugin, and updates/runtimeVersion keys in app.json. */
168
+ export function applyEdits(dir) {
169
+ const jobs = [
170
+ { file: "package.json", edit: (o) => {
171
+ const r = [];
172
+ for (const sec of ["dependencies", "devDependencies"])
173
+ if (o?.[sec]?.["expo-updates"]) {
174
+ r.push(`package.json ${sec}: expo-updates`);
175
+ delete o[sec]["expo-updates"];
176
+ }
177
+ return r;
178
+ } },
179
+ { file: "app.json", edit: (o) => {
180
+ const expo = o?.expo ?? o;
181
+ const r = [];
182
+ if (!expo || typeof expo !== "object")
183
+ return r;
184
+ if (Array.isArray(expo.plugins) && expo.plugins.some(isPlugin)) {
185
+ expo.plugins = expo.plugins.filter((x) => !isPlugin(x));
186
+ r.push("app.json plugins: expo-updates");
187
+ }
188
+ for (const k of ["updates", "runtimeVersion"])
189
+ if (k in expo) {
190
+ delete expo[k];
191
+ r.push(`app.json: ${k}`);
192
+ }
193
+ for (const os of ["ios", "android"])
194
+ if (expo[os] && typeof expo[os] === "object" && "runtimeVersion" in expo[os]) {
195
+ delete expo[os].runtimeVersion;
196
+ r.push(`app.json: ${os}.runtimeVersion`);
197
+ }
198
+ return r;
199
+ } },
200
+ ];
201
+ const pending = [];
202
+ for (const j of jobs) {
203
+ const path = join(dir, j.file);
204
+ if (!existsSync(path))
205
+ continue;
206
+ let e;
207
+ try {
208
+ e = editJson(readFileSync(path, "utf8"), j.edit);
209
+ }
210
+ catch {
211
+ continue;
212
+ }
213
+ if (e)
214
+ pending.push({ file: j.file, ...e });
215
+ }
216
+ for (const f of pending) {
217
+ const b = join(dir, BACKUP, f.file);
218
+ mkdirSync(dirname(b), { recursive: true });
219
+ copyFileSync(join(dir, f.file), b);
220
+ }
221
+ for (const f of pending)
222
+ writeFileSync(join(dir, f.file), f.text);
223
+ return pending.map((f) => ({ file: f.file, removed: f.removed }));
224
+ }
225
+ function dirtyFiles(dir) {
226
+ const r = spawnSync("git", ["status", "--porcelain"], { cwd: dir, encoding: "utf8" });
227
+ if (r.error || r.status !== 0)
228
+ return [];
229
+ return String(r.stdout).split("\n").filter(Boolean);
230
+ }
231
+ function applyEdits0(dir, force) {
232
+ // Nothing to edit means nothing to refuse: a second run must exit 0 even though the backups are untracked.
233
+ const probe = buildPlan(dir).found.some((f) => f.kind !== "dependency" || f.entry.startsWith("expo-updates@"));
234
+ if (!probe)
235
+ return [];
236
+ if (!force) {
237
+ const d = dirtyFiles(dir);
238
+ if (d.length)
239
+ throw new CliFailure(`Git tree is not clean (${d.length} changed path(s)). Commit or stash first, or pass --force.`, "dirty_tree", 1, [{ command: "git status", why: "See what changed." }, { command: "shipvo migrate --apply --force", why: "Apply anyway. Backups go in " + BACKUP + "." }]);
240
+ }
241
+ return applyEdits(dir);
242
+ }
243
+ const CP_PKG = ["react-native-code-push", "@revopush/react-native-code-push"];
244
+ const CP_IMPORT = /^import\s[^;]*?\bfrom\s*['"](?:@revopush\/)?react-native-code-push['"];?[ \t]*$/;
245
+ const CP_ARGS = String.raw `\((?:[^()]|\([^()]*\))*\)`;
246
+ const CP_WRAP = new RegExp(String.raw `\bcodePush\s*${CP_ARGS}\s*\(\s*(\w+)\s*\)`);
247
+ const CP_SYNC = new RegExp(String.raw `\bCodePush\.sync\s*${CP_ARGS}`);
248
+ const CP_READY = /\bCodePush\.notifyAppReady\s*\(\s*\)/;
249
+ const isComment = (l) => /^\s*(\/\/|\*)/.test(l);
250
+ /** Rewrites one code file. Single-line patterns only; anything else is left for the manual list. */
251
+ function editCode(raw) {
252
+ if (!/react-native-code-push|\bcodePush\b|\bCodePush\b/.test(raw))
253
+ return null;
254
+ const removed = [];
255
+ const lines = raw.split("\n");
256
+ const imports = [];
257
+ const out = lines.map((l, i) => {
258
+ if (isComment(l))
259
+ return l;
260
+ if (CP_IMPORT.test(l.trim())) {
261
+ imports.push(i);
262
+ return l;
263
+ }
264
+ let r = l;
265
+ if (CP_WRAP.test(r)) {
266
+ r = r.replace(CP_WRAP, "$1");
267
+ removed.push(`${i + 1}: codePush() wrapper`);
268
+ }
269
+ if (CP_SYNC.test(r)) {
270
+ r = r.replace(CP_SYNC, "Runtime.checkForUpdate(true)");
271
+ removed.push(`${i + 1}: CodePush.sync`);
272
+ }
273
+ if (CP_READY.test(r)) {
274
+ r = r.replace(CP_READY, "Runtime.markHealthy()");
275
+ removed.push(`${i + 1}: CodePush.notifyAppReady`);
276
+ }
277
+ return r;
278
+ });
279
+ if (!removed.length)
280
+ return null;
281
+ const usesRuntime = out.some((l) => /\bRuntime\./.test(l));
282
+ const hasRuntime = /import[^;]*\bRuntime\b[^;]*from\s*['"]@shipvo\/runtime['"]/.test(out.join("\n"));
283
+ const bound = new Set(["codePush", "CodePush"]);
284
+ for (const i of imports) {
285
+ const m = /^import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?/.exec(lines[i].trim());
286
+ if (m?.[1])
287
+ bound.add(m[1]);
288
+ for (const part of (m?.[2] ?? "").split(",")) {
289
+ const name = part.trim().split(/\s+as\s+/).pop()?.replace(/^type\s+/, "").trim();
290
+ if (name && /^\w+$/.test(name))
291
+ bound.add(name);
292
+ }
293
+ }
294
+ const boundRe = new RegExp(String.raw `\b(?:${[...bound].join("|")})\b`);
295
+ const stillCp = out.some((l, i) => !imports.includes(i) && !isComment(l) && boundRe.test(l));
296
+ if (imports.length && !stillCp) {
297
+ const first = imports[0];
298
+ for (const i of imports.slice().reverse()) {
299
+ if (i === first && usesRuntime && !hasRuntime)
300
+ out[i] = 'import { Runtime } from "@shipvo/runtime";';
301
+ else
302
+ out.splice(i, 1);
303
+ }
304
+ removed.push("import react-native-code-push");
305
+ }
306
+ else if (usesRuntime && !hasRuntime) {
307
+ out.splice(imports.length ? imports[0] + 1 : 0, 0, 'import { Runtime } from "@shipvo/runtime";');
308
+ }
309
+ return { text: out.join("\n"), removed };
310
+ }
311
+ function editNative(raw, base) {
312
+ const removed = [];
313
+ let text = raw;
314
+ for (const key of ["CodePushDeploymentKey", "CodePushPublicKey"]) {
315
+ const re = base === "Info.plist"
316
+ ? new RegExp(String.raw `[ \t]*<key>${key}</key>[ \t]*\r?\n[ \t]*<string>[^<]*</string>[ \t]*\r?\n?`)
317
+ : new RegExp(String.raw `[ \t]*<string\s+name="${key}"[^>]*>[^<]*</string>[ \t]*\r?\n?`);
318
+ if (re.test(text)) {
319
+ text = text.replace(re, "");
320
+ removed.push(key);
321
+ }
322
+ }
323
+ return removed.length ? { text, removed } : null;
324
+ }
325
+ /** CodePush --apply: wrapper and sync calls, native keys, and the dependency. Never runs a package manager. */
326
+ export function applyCodePushEdits(dir, dry = false) {
327
+ const pending = [];
328
+ for (const file of walk(dir)) {
329
+ const base = file.split("/").pop();
330
+ const native = base === "Info.plist" || base === "strings.xml";
331
+ if (file === "package.json" || (!native && !CODE_EXT.test(file)))
332
+ continue;
333
+ let raw;
334
+ try {
335
+ raw = readFileSync(join(dir, file), "utf8");
336
+ }
337
+ catch {
338
+ continue;
339
+ }
340
+ const e = native ? editNative(raw, base) : editCode(raw);
341
+ if (e)
342
+ pending.push({ file, text: e.text, removed: e.removed.map((r) => `${file} ${r}`) });
343
+ }
344
+ const pkgPath = join(dir, "package.json");
345
+ if (existsSync(pkgPath)) {
346
+ const raw = readFileSync(pkgPath, "utf8");
347
+ let e = null;
348
+ try {
349
+ e = editJson(raw, (o) => {
350
+ const r = [];
351
+ for (const sec of ["dependencies", "devDependencies"])
352
+ for (const n of CP_PKG)
353
+ if (o?.[sec]?.[n]) {
354
+ delete o[sec][n];
355
+ r.push(`package.json ${sec}: ${n}`);
356
+ }
357
+ if (!r.length)
358
+ return r;
359
+ o.dependencies = { ...(o.dependencies ?? {}) };
360
+ o.devDependencies = { ...(o.devDependencies ?? {}) };
361
+ for (const n of ["@shipvo/runtime", "@shipvo/device"])
362
+ if (!o.dependencies[n]) {
363
+ o.dependencies[n] = "^0.1.0";
364
+ r.push(`package.json dependencies: added ${n}`);
365
+ }
366
+ if (!o.devDependencies["@shipvo/cli"]) {
367
+ o.devDependencies["@shipvo/cli"] = "^0.1.0";
368
+ r.push("package.json devDependencies: added @shipvo/cli");
369
+ }
370
+ for (const sec of ["dependencies", "devDependencies"])
371
+ if (!Object.keys(o[sec]).length)
372
+ delete o[sec];
373
+ return r;
374
+ });
375
+ }
376
+ catch { /* unreadable package.json: leave it */ }
377
+ if (e)
378
+ pending.push({ file: "package.json", ...e });
379
+ }
380
+ if (!dry) {
381
+ for (const f of pending) {
382
+ const b = join(dir, BACKUP, f.file);
383
+ mkdirSync(dirname(b), { recursive: true });
384
+ copyFileSync(join(dir, f.file), b);
385
+ }
386
+ for (const f of pending)
387
+ writeFileSync(join(dir, f.file), f.text);
388
+ }
389
+ return pending.map((f) => ({ file: f.file, removed: f.removed }));
390
+ }
391
+ function applyCodePush(dir, force) {
392
+ // Same rule as the EAS path: nothing to edit means nothing to refuse.
393
+ if (!applyCodePushEdits(dir, true).length)
394
+ return [];
395
+ if (!force) {
396
+ const d = dirtyFiles(dir);
397
+ if (d.length)
398
+ throw new CliFailure(`Git tree is not clean (${d.length} changed path(s)). Commit or stash first, or pass --force.`, "dirty_tree", 1, [{ command: "git status", why: "See what changed." }, { command: "shipvo migrate --apply --force", why: "Apply anyway. Backups go in " + BACKUP + "." }]);
399
+ }
400
+ return applyCodePushEdits(dir);
401
+ }
402
+ const NATIVE_SRC = /(?:\.(?:java|kt|m|mm|swift|gradle)|(?:^|\/)Podfile)$/;
403
+ /** Native bootstrap files that still mention CodePush. --apply never edits them, so they are listed as manual. */
404
+ function scanNativeBootstrap(dir) {
405
+ const out = [];
406
+ for (const file of walk(dir)) {
407
+ if (!NATIVE_SRC.test(file))
408
+ continue;
409
+ let lines;
410
+ try {
411
+ lines = readFileSync(join(dir, file), "utf8").split("\n");
412
+ }
413
+ catch {
414
+ continue;
415
+ }
416
+ const i = lines.findIndex((l) => /CodePush|code-push/.test(l));
417
+ if (i >= 0)
418
+ out.push({ where: `${file}:${i + 1}`, entry: lines[i].trim().slice(0, 120), why: "Native code still uses CodePush and --apply does not edit native files: remove it by hand or the native build will fail once the dependency is gone" });
419
+ }
420
+ return out;
421
+ }
422
+ const buildPlanPm = (dir) => detectProject(dir).packageManager;
423
+ export async function migrate(argv, dir = process.cwd()) {
424
+ dir = resolve(dir);
425
+ const json = argv.includes("--json");
426
+ const run = async () => {
427
+ const apply = argv.includes("--apply");
428
+ if (!apply && !argv.includes("--plan"))
429
+ throw new UsageError("shipvo migrate needs --plan (print the steps) or --apply (make the safe edits).");
430
+ if (apply && argv.includes("--plan"))
431
+ throw new UsageError("Use --plan or --apply, not both.");
432
+ const plan = buildPlan(dir);
433
+ if (apply && plan.system === "expo-updates") {
434
+ const edits = applyEdits0(dir, argv.includes("--force"));
435
+ const pm = buildPlanPm(dir);
436
+ if (!edits.length) {
437
+ if (!json)
438
+ console.log("Nothing left to do: no expo-updates entries remain in package.json or app.json.");
439
+ return { code: "nothing_left", summary: "Nothing left to do: no expo-updates entries remain that --apply can edit.", data: { changed: [], backupDir: BACKUP }, next: [{ command: "shipvo init --dry-run", why: "Preview what init adds." }] };
440
+ }
441
+ const rest = plan.steps.filter((s) => !/^Remove (expo-updates from package|the expo-updates entry)/.test(s) && !/^Commit your work/.test(s));
442
+ if (!json) {
443
+ console.log("Changed:");
444
+ for (const e of edits)
445
+ for (const r of e.removed)
446
+ console.log(` - ${r}`);
447
+ console.log(`\nBackups are in ${BACKUP}/.`);
448
+ console.log("Old binaries keep using EAS Update until a new store build ships.");
449
+ console.log("\nNext steps:");
450
+ console.log(` - Run ${pm} install to refresh the lockfile.`);
451
+ rest.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
452
+ console.log("\nI did not run shipvo init. Run it yourself when ready.");
453
+ }
454
+ return { code: "applied", summary: `Removed ${edits.reduce((n, e) => n + e.removed.length, 0)} entries from ${edits.length} file(s). Backups in ${BACKUP}.`, data: { changed: edits, backupDir: BACKUP, steps: rest }, next: [{ command: `${pm} install`, why: "Refresh the lockfile after removing expo-updates." }, { command: "shipvo init", why: "Add Shipvo. It is not run for you." }] };
455
+ }
456
+ if (apply && plan.system === "codepush") {
457
+ const edits = applyCodePush(dir, argv.includes("--force"));
458
+ const left = scanCodePush(dir);
459
+ const manual = [...left.filter((f) => f.kind === "command" || f.kind === "wrapper" || f.kind === "sync")
460
+ .map((f) => ({ where: f.where, entry: f.entry, why: f.kind === "command" ? "appcenter codepush command: replace with shipvo publish or shipvo rollback" : "CodePush call --apply could not rewrite: change it by hand" })), ...scanNativeBootstrap(dir)];
461
+ const pm = buildPlanPm(dir);
462
+ if (!edits.length) {
463
+ if (!json) {
464
+ console.log("Nothing left to do: no CodePush code, keys or dependency remain that --apply can edit.");
465
+ for (const m of manual)
466
+ console.log(` manual: ${m.where}: ${m.entry}`);
467
+ }
468
+ return { code: "nothing_left", summary: "Nothing left to do: no CodePush entries remain that --apply can edit.", data: { changed: [], manual, backupDir: BACKUP }, next: [{ command: "shipvo init --dry-run", why: "Preview what init adds." }] };
469
+ }
470
+ if (!json) {
471
+ console.log("Changed:");
472
+ for (const e of edits) {
473
+ console.log(` ${e.file}`);
474
+ for (const r of e.removed)
475
+ console.log(` - ${r}`);
476
+ }
477
+ console.log(`\nBackups are in ${BACKUP}/.`);
478
+ if (manual.length) {
479
+ console.log("\nManual steps (left in place):");
480
+ for (const m of manual)
481
+ console.log(` - ${m.where}: ${m.entry}\n ${m.why}`);
482
+ }
483
+ console.log(`\nNext: run ${pm} install to refresh the lockfile. I did not run it.`);
484
+ console.log("Then create the Shipvo config (shipvo init in a scratch folder), build one new store build, and publish. Old binaries keep using CodePush.");
485
+ console.log("See docs/migrate-from-codepush.md.");
486
+ }
487
+ return { code: "applied", summary: `Edited ${edits.length} file(s). Backups in ${BACKUP}. ${manual.length} manual step(s) left.`, data: { changed: edits, manual, backupDir: BACKUP, steps: plan.steps }, next: [{ command: `${pm} install`, why: "Refresh the lockfile after the dependency swap." }, { command: "shipvo init --dry-run", why: "Preview the Shipvo config; copy it into your app." }] };
488
+ }
489
+ if (!plan.system) {
490
+ if (!json)
491
+ console.log(apply ? "Nothing left to do: this app has no expo-updates or CodePush." : "Nothing to migrate: this app has no expo-updates or CodePush.");
492
+ return { code: "nothing_to_migrate", summary: "Nothing to migrate: no expo-updates or CodePush found.", data: plan, next: [{ command: "shipvo init", why: "Add Shipvo to this app." }] };
493
+ }
494
+ if (!json) {
495
+ console.log(`Found ${plan.system === "expo-updates" ? "expo-updates (EAS Update)" : "CodePush"}:`);
496
+ for (const f of plan.found)
497
+ console.log(` - ${f.where}: ${f.entry}`);
498
+ console.log("\nSteps, in this order:");
499
+ plan.steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
500
+ console.log(`\nNo files were changed. Details: ${plan.system === "codepush" ? "docs/migrate-from-codepush.md" : DOC}`);
501
+ }
502
+ return {
503
+ code: "plan", summary: `${plan.found.length} entries found, ${plan.steps.length} steps. No files changed.`, data: plan,
504
+ next: plan.system === "expo-updates"
505
+ ? [{ command: `${buildPlanPm(dir)} remove expo-updates`, why: "Step 1 of the plan." }, { command: "shipvo init --dry-run", why: "Preview what init adds once expo-updates is gone." }]
506
+ : [{ command: "shipvo init --dry-run", why: "Preview what init adds." }, { command: "cat docs/migrate-from-codepush.md", why: "Read the CodePush guide." }],
507
+ };
508
+ };
509
+ if (json) {
510
+ await runJson(run, [{ command: "shipvo help", why: "See the commands and flags." }]);
511
+ return Number(process.exitCode ?? 0);
512
+ }
513
+ try {
514
+ await run();
515
+ return 0;
516
+ }
517
+ catch (e) {
518
+ console.error(e instanceof Error ? e.message : String(e));
519
+ return e instanceof CliFailure ? e.exit : 1;
520
+ }
521
+ }
package/dist/preview.d.ts CHANGED
@@ -26,6 +26,7 @@ export declare function framePage(name: string, appId?: string): string;
26
26
  export declare function wrapBuild(buildDir: string, name: string, appId?: string): PreviewFile[];
27
27
  /** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
28
28
  export declare function publishToHereNow(files: PreviewFile[], o?: PreviewOpts): Promise<PreviewResult>;
29
+ export declare function build(root: string, out: string): Promise<void>;
29
30
  export declare const NOTICE = "This link is public: anyone who has it can open the preview. It expires in 24 hours unless you open the claim link and sign in to here.now, which keeps it.";
30
31
  /** Builds, wraps and publishes. Used by the CLI and by the MCP `publish_preview` tool (through `shipvo preview --json`). */
31
32
  export declare function runPreview(root: string, o?: PreviewOpts & {
package/dist/preview.js CHANGED
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
5
5
  import { join, relative, sep, extname } from "node:path";
6
6
  import { args as parse, loadConfig } from "./config.js";
7
+ import { spawnBin } from "./spawn-bin.js";
7
8
  import { RECORDER_JS } from "./preview-recorder.js";
8
9
  const API = "https://here.now/api/v1";
9
10
  const CLIENT = "shipvo/cli";
@@ -302,11 +303,12 @@ export async function publishToHereNow(files, o = {}) {
302
303
  const warnings = (done.warnings ?? []).map((w) => (typeof w === "string" ? w : JSON.stringify(w)));
303
304
  return { url: done.siteUrl ?? created.siteUrl, claimUrl: created.claimUrl ?? null, expiresAt: created.expiresAt ?? null, files: files.length, warnings };
304
305
  }
305
- function build(root, out) {
306
+ export function build(root, out) {
306
307
  rmSync(out, { recursive: true, force: true });
307
308
  mkdirSync(join(root, ".shipvo"), { recursive: true });
308
309
  return new Promise((resolve, reject) => {
309
- const p = spawn("npx", ["expo", "export", "--platform", "web", "--clear", "--output-dir", out], { cwd: root, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "inherit"] });
310
+ const call = spawnBin("npx", ["expo", "export", "--platform", "web", "--clear", "--output-dir", out]);
311
+ const p = spawn(call.command, call.args, { cwd: root, shell: call.shell, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "inherit"] });
310
312
  p.on("error", reject);
311
313
  p.on("exit", (c) => (c === 0 ? resolve() : reject(new Error(`The web build failed (exit ${c}). Run \`npx expo export --platform web\` to see why.`))));
312
314
  });
package/dist/promote.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { kv as kvFor } from "./config.js";
2
2
  type Store = Pick<ReturnType<typeof kvFor>, "get" | "put">;
3
3
  export declare const PROMOTE_FLAGS: string[];
4
- export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)\n A report for this release with failing flows blocks promote\n --force Promote even if the release is halted, has reverts or has a failing test report\n --help Show this help\n";
4
+ export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)\n A report for this release with failing flows blocks promote\n --json Print one JSON envelope on stdout; text goes to stderr\n --force Promote even if the release is halted, has reverts or has a failing test report\n --help Show this help\n";
5
5
  export declare function promote(argv: string[], deps?: {
6
6
  kv?: Store;
7
7
  fetchFn?: typeof fetch;