@shipvo/cli 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +29 -0
  4. package/dist/agents.js +183 -0
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +92 -0
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +11 -0
  20. package/dist/ci.js +100 -0
  21. package/dist/cli.js +51 -1
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.js +68 -13
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +88 -8
  26. package/dist/eas.d.ts +29 -0
  27. package/dist/eas.js +258 -0
  28. package/dist/ensure-playwright.d.ts +25 -0
  29. package/dist/ensure-playwright.js +69 -0
  30. package/dist/explore-issues.d.ts +12 -0
  31. package/dist/explore-issues.js +50 -0
  32. package/dist/explore.d.ts +160 -0
  33. package/dist/explore.js +496 -0
  34. package/dist/flows.d.ts +31 -0
  35. package/dist/flows.js +264 -0
  36. package/dist/help.d.ts +2 -2
  37. package/dist/help.js +37 -7
  38. package/dist/hermes.d.ts +10 -0
  39. package/dist/hermes.js +50 -0
  40. package/dist/init.d.ts +1 -0
  41. package/dist/init.js +71 -19
  42. package/dist/json-out.d.ts +53 -0
  43. package/dist/json-out.js +91 -0
  44. package/dist/keys-json.d.ts +1 -0
  45. package/dist/keys-json.js +71 -0
  46. package/dist/login.js +27 -11
  47. package/dist/lumi.d.ts +104 -0
  48. package/dist/lumi.js +364 -0
  49. package/dist/migrate.d.ts +27 -0
  50. package/dist/migrate.js +521 -0
  51. package/dist/policy.d.ts +2 -16
  52. package/dist/policy.js +2 -18
  53. package/dist/preview-recorder.d.ts +1 -0
  54. package/dist/preview-recorder.js +62 -0
  55. package/dist/preview.d.ts +4 -3
  56. package/dist/preview.js +114 -15
  57. package/dist/promote.d.ts +1 -1
  58. package/dist/promote.js +63 -14
  59. package/dist/publish.d.ts +14 -2
  60. package/dist/publish.js +131 -56
  61. package/dist/qr.d.ts +9 -0
  62. package/dist/qr.js +213 -0
  63. package/dist/report-md.d.ts +27 -0
  64. package/dist/report-md.js +58 -0
  65. package/dist/rotation.d.ts +57 -0
  66. package/dist/rotation.js +157 -0
  67. package/dist/run-bridge.d.ts +7 -0
  68. package/dist/run-bridge.js +728 -0
  69. package/dist/run-net.d.ts +39 -0
  70. package/dist/run-net.js +266 -0
  71. package/dist/run-protocol.d.ts +32 -0
  72. package/dist/run-protocol.js +22 -0
  73. package/dist/run-ws.d.ts +8 -0
  74. package/dist/run-ws.js +89 -0
  75. package/dist/run.d.ts +109 -0
  76. package/dist/run.js +385 -0
  77. package/dist/signing.js +13 -0
  78. package/dist/sim.d.ts +4 -0
  79. package/dist/sim.js +25 -7
  80. package/dist/smoke.d.ts +28 -0
  81. package/dist/smoke.js +117 -0
  82. package/dist/spawn-bin.d.ts +11 -0
  83. package/dist/spawn-bin.js +21 -0
  84. package/dist/status.d.ts +71 -1
  85. package/dist/status.js +162 -5
  86. package/dist/store.d.ts +36 -0
  87. package/dist/store.js +266 -3
  88. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  89. package/dist/templates/github-action/shipvo-check.yml +18 -0
  90. package/dist/test.d.ts +46 -0
  91. package/dist/test.js +253 -0
  92. package/package.json +7 -4
  93. package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/publish.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // `shipvo publish`: build the Hermes bundle, sign a release pointer, publish bundle + patches + the agent-facing web
2
2
  // artifact to the control plane. `shipvo rollback --to N`: republish an earlier bundle as a new release.
3
3
  import { execSync } from "node:child_process";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { dirname, join, relative, resolve } from "node:path";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import path, { dirname, join, relative, resolve } from "node:path";
7
6
  import { sha256Hex, signBytes } from "./index.js";
8
- import { checkBundle, parseDeclared } from "./hermes.js";
7
+ import { bsdiff } from "./bsdiff.js";
8
+ import { checkBundle, checkEngine, installedHermesEngine, parseDeclared, parseEngine } from "./hermes.js";
9
9
  import { requiredNativeModules } from "./native.js";
10
10
  import { checkPublishPolicy } from "./policy.js";
11
- import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
11
+ import { CliFailure, UsageError, runJson } from "./json-out.js";
12
+ import { args as parse, hasStore, kv as kvFor, loadConfig, offlineKv, seed } from "./config.js";
12
13
  const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "application/json", ".css": "text/css", ".ico": "image/x-icon", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ttf": "font/ttf", ".svg": "image/svg+xml" };
13
14
  const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
14
15
  /** Release numbers are shared across platforms, so one number means one commit everywhere. */
@@ -34,7 +35,7 @@ const platformsOf = (a) => {
34
35
  return ["android", "ios"];
35
36
  if (p === "android" || p === "ios")
36
37
  return [p];
37
- throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
38
+ throw new UsageError(`--platform must be android, ios or all (got "${a.platform}")`);
38
39
  };
39
40
  /** Unknown flags are errors: a typo like --dryrun must not fall through to a real publish. */
40
41
  function checkFlags(argv, known) {
@@ -42,7 +43,7 @@ function checkFlags(argv, known) {
42
43
  if (bad.length) {
43
44
  const eq = bad.find((x) => x.includes("="));
44
45
  const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
45
- throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
46
+ throw new UsageError(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
46
47
  }
47
48
  }
48
49
  /** An on/off flag: bare or "true" is on, "false" is off. Anything else is an error, so a typo never flips it silently. */
@@ -52,7 +53,12 @@ function switchFlag(a, name) {
52
53
  return false;
53
54
  if (v === "true")
54
55
  return true;
55
- throw new Error(`--${name} is an on/off flag: use --${name}, --${name} true or --${name} false (got "${v}")`);
56
+ throw new UsageError(`--${name} is an on/off flag: use --${name}, --${name} true or --${name} false (got "${v}")`);
57
+ }
58
+ /** True when `file` is strictly inside `root`. Uses the platform's separator, so it works with Windows paths too. */
59
+ export function isInside(root, file, p = path) {
60
+ const rel = p.relative(root, file);
61
+ return rel !== "" && !rel.startsWith("..") && !p.isAbsolute(rel);
56
62
  }
57
63
  /** A store that only writes files under `dir` (key `p/app/1/b0` becomes `dir/p/app/1/b0`). It reads back only what this run wrote (kept in memory), never old files, so the iOS pass can find the web artifact and a dry run never needs a control plane. */
58
64
  function localKv(dir) {
@@ -65,7 +71,7 @@ function localKv(dir) {
65
71
  async keys(prefix) { return [...mem.keys()].filter((k) => k.startsWith(prefix)); },
66
72
  async put(k, body, _type) {
67
73
  const f = resolve(root, k);
68
- if (!f.startsWith(root + "/"))
74
+ if (!isInside(root, f))
69
75
  throw new Error(`refusing to write outside ${dir}: ${k}`);
70
76
  const bytes = typeof body === "string" ? enc.encode(body) : new Uint8Array(body);
71
77
  mkdirSync(dirname(f), { recursive: true });
@@ -74,7 +80,7 @@ function localKv(dir) {
74
80
  },
75
81
  };
76
82
  }
77
- const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "out"];
83
+ const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "dry-run-policy", "out", "json"];
78
84
  export const PUBLISH_HELP = `Usage: shipvo publish [flags]
79
85
 
80
86
  Build, sign and publish an over-the-air release.
@@ -89,28 +95,48 @@ Flags (write a value after a space, not =; on/off flags can be bare, or true/fal
89
95
  --skip-export Reuse the last build in dist-ota/
90
96
  --skip-web Do not publish the web copy for the simulator
91
97
  --dry-run Build and sign, write files locally, send nothing
98
+ --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing
92
99
  --out <dir> Where --dry-run writes files (default .shipvo/dry-run)
100
+ --json Print one JSON envelope on stdout; text and build output go to stderr
93
101
  --help Show this help
94
102
  `;
103
+ const DOCTOR = [{ command: "shipvo doctor --json", why: "Check your setup." }];
95
104
  export async function publish(argv) {
96
105
  if (argv.includes("--help") || argv.includes("-h")) {
97
106
  console.log(PUBLISH_HELP);
98
107
  return;
99
108
  }
109
+ if (argv.includes("--json"))
110
+ return runJson(() => publishRun(argv, true), DOCTOR);
111
+ await publishRun(argv, false);
112
+ }
113
+ async function publishRun(argv, json) {
114
+ // In --json mode the build tools must not write to stdout either.
115
+ const io = json ? ["inherit", 2, 2] : "inherit";
100
116
  checkFlags(argv, PUBLISH_FLAGS);
101
117
  const a = parse(argv), cfg = loadConfig(), SEED = seed();
118
+ if (switchFlag(a, "dry-run-policy")) {
119
+ const verdict = checkPublishPolicy(cfg.policy, (a.bands || "b0,b1,b2").split(","));
120
+ if (verdict.ok) {
121
+ const msg = cfg.policy === undefined ? "No policy in shipvo.json: nothing to check." : "Policy allows this publish.";
122
+ console.log(msg);
123
+ return { code: "policy_ok", summary: msg, data: { policy: cfg.policy === undefined ? "none" : "allowed", bands: (a.bands || "b0,b1,b2").split(",") }, next: [{ command: "shipvo publish --dry-run --json", why: "Build and sign without sending." }] };
124
+ }
125
+ return refused(verdict, json);
126
+ }
102
127
  const dry = switchFlag(a, "dry-run"), skipExport = switchFlag(a, "skip-export"), skipWeb = switchFlag(a, "skip-web");
103
128
  if (a.out !== undefined && !dry)
104
- throw new Error("--out only works with --dry-run");
129
+ throw new UsageError("--out only works with --dry-run");
105
130
  if (a.out === "true")
106
- throw new Error("--out needs a directory: pass --out <dir>");
131
+ throw new UsageError("--out needs a directory: pass --out <dir>");
107
132
  const outDir = a.out || ".shipvo/dry-run";
108
133
  const kv = dry ? localKv(outDir) : kvFor(cfg);
109
134
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
110
135
  const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
111
136
  if (a["hermes-bytecode-version"] === "true")
112
- throw new Error("--hermes-bytecode-version needs a value: pass a value like 96 (or 96,98)");
137
+ throw new UsageError("--hermes-bytecode-version needs a value: pass a value like 96 (or 96,98)");
113
138
  const declaredHermes = parseDeclared(a["hermes-bytecode-version"] ?? cfg.hermesBytecodeVersion);
139
+ const hermesEngine = checkEngine(installedHermesEngine(process.cwd()), parseEngine(cfg.hermesEngine)); // fails before anything is uploaded
114
140
  const skippedNative = [];
115
141
  const nativeModules = requiredNativeModules(".", skippedNative);
116
142
  if (skippedNative.length)
@@ -120,11 +146,8 @@ export async function publish(argv) {
120
146
  const bands = (a.bands || "b0,b1,b2").split(",");
121
147
  if (dry) {
122
148
  const verdict = checkPublishPolicy(cfg.policy, bands);
123
- if (!verdict.ok) {
124
- console.error(`Refused by policy rule ${verdict.rule}: ${verdict.reason}`);
125
- process.exitCode = 1;
126
- return;
127
- }
149
+ if (!verdict.ok)
150
+ return refused(verdict, json);
128
151
  }
129
152
  const platforms = platformsOf(a);
130
153
  const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
@@ -132,16 +155,16 @@ export async function publish(argv) {
132
155
  if (dry)
133
156
  console.error(`dry run: nothing is sent anywhere; files go to ${outDir}. No patches are built offline. Release is ${release}${a.release ? "" : " (pass --release N to match the control plane)"}.`);
134
157
  let totalBytes = 0;
135
- const patchInfo = { bases: 0, bsdiff: false, patches: 0 };
158
+ const patchInfo = { bases: 0, patches: 0 };
136
159
  for (const pf of platforms) {
137
160
  // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
138
161
  const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
139
162
  if (!skipExport)
140
- execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: "inherit" });
163
+ execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: io });
141
164
  const jsDir = `${root}/_expo/static/js/${pf}`;
142
165
  const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
143
166
  if (!file)
144
- throw new Error("no bundle in " + jsDir);
167
+ throw new CliFailure("no bundle in " + jsDir, "no_bundle", 1, [{ command: "shipvo publish --json", why: "Run again without --skip-export so the bundle is built." }]);
145
168
  const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
146
169
  const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
147
170
  const name = bundleName(release, pf), newSha = sha256Hex(bytes);
@@ -169,29 +192,23 @@ export async function publish(argv) {
169
192
  }
170
193
  catch { }
171
194
  }
172
- // Only look for bsdiff4 when there is an older bundle to patch from; a first release needs no patches.
173
- let bsdiff = false;
195
+ // Memory grows with bundle size (plain arrays); only a thrown error falls back to the full bundle.
196
+ // Patches are built in-process (no Python or other tool); a first release has no older bundle to patch from.
174
197
  if (bases.size > 0) {
175
- try {
176
- execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
177
- bsdiff = true;
178
- }
179
- catch {
180
- console.error("Small patches are off because the Python tool bsdiff4 is not installed (pip install bsdiff4). Full bundles are published instead. Updates still work, they are just bigger.");
181
- }
182
- }
183
- if (bsdiff) {
184
- const work = mkdtempSync(join(tmpdir(), "shipvo-"));
185
- writeFileSync(join(work, "new.hbc"), bytes);
186
198
  for (const [fromSha, key] of bases) {
187
199
  if (fromSha === newSha)
188
200
  continue;
189
201
  const base = await kv.getBytes(key);
190
202
  if (!base)
191
203
  continue;
192
- writeFileSync(join(work, "old.hbc"), base);
193
- execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
194
- const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
204
+ let patch;
205
+ try {
206
+ patch = bsdiff(new Uint8Array(base), bytes);
207
+ }
208
+ catch {
209
+ console.error(`Could not build a small patch from release ${fromSha.slice(0, 8)}; publishing the full bundle`);
210
+ continue;
211
+ }
195
212
  if (patch.length >= bytes.length * 0.6)
196
213
  continue;
197
214
  const pkey = `b/${APP}/${pf === "ios" ? "ios-" : ""}d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
@@ -200,14 +217,13 @@ export async function publish(argv) {
200
217
  }
201
218
  }
202
219
  patchInfo.bases = bases.size;
203
- patchInfo.bsdiff = bsdiff;
204
220
  patchInfo.patches = patches.length;
205
221
  console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
206
222
  }
207
223
  // The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator (built once, with the first platform).
208
224
  let web = null;
209
225
  if (!skipWeb && pf === platforms[0]) {
210
- web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined);
226
+ web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined, io);
211
227
  }
212
228
  else if (!skipWeb) {
213
229
  try {
@@ -219,10 +235,10 @@ export async function publish(argv) {
219
235
  for (const band of bands) {
220
236
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
221
237
  core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
222
- addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(web ? { web } : {}) });
238
+ addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(hermesVersion && hermesEngine ? { hermes_engine: hermesEngine } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(web ? { web } : {}) });
223
239
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
224
240
  }
225
- await kv.put(pf === "ios" ? `h/${APP}/ios-r${release}` : `h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), web }), "application/json");
241
+ await kv.put(pf === "ios" ? `h/${APP}/ios-r${release}` : `h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(hermesVersion && hermesEngine ? { hermes_engine: hermesEngine } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), web }), "application/json");
226
242
  totalBytes += bytes.length;
227
243
  summary[pf] = { bytes: bytes.length, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) };
228
244
  if (web)
@@ -236,14 +252,41 @@ export async function publish(argv) {
236
252
  if (dry)
237
253
  console.log(dryRunSummary(release, totalBytes, outDir, patchInfo));
238
254
  else
239
- console.log(JSON.stringify(summary));
255
+ console.log(publishedSummary(release, bands));
256
+ const flags = [a.bands ? `--bands ${a.bands}` : "", a.platform ? `--platform ${a.platform}` : ""].filter(Boolean).join(" ");
257
+ const data = { ...summary, platforms, total_bytes: totalBytes };
258
+ if (dry)
259
+ return { code: "dry_run", summary: `Dry run complete: release ${release}, ${formatSize(totalBytes)}. Nothing was sent.`, data, next: [{ command: `shipvo publish ${flags} --json`.replace(/ +/g, " "), why: "Publish for real." }] };
260
+ const rest = ["b0", "b1", "b2"].filter((b) => !bands.includes(b));
261
+ const next = [{ command: "shipvo status --json", why: "See how the release does on phones." }];
262
+ if (rest.length)
263
+ next.push({ command: `shipvo promote --to ${rest.join(",")} --json`, why: `Widen release ${release} to ${rest.join(", ")} when it looks healthy.` });
264
+ return { code: "published", summary: `Published release ${release} to ${bands.join(", ")} (${platforms.join(", ")}).`, data, next };
265
+ }
266
+ function refused(verdict, json) {
267
+ const msg = `Refused by policy rule ${verdict.rule}: ${verdict.reason}`;
268
+ if (json)
269
+ throw new CliFailure(msg, "policy_refused", 1, [{ command: "shipvo publish --dry-run-policy --json", why: "Check the policy for other --bands." }]);
270
+ console.error(msg);
271
+ process.exitCode = 1;
272
+ return { code: "policy_refused", summary: msg, data: {}, next: [] };
273
+ }
274
+ /** The publish result in plain words. `--json` carries the full detail. */
275
+ export function publishedSummary(release, bands) {
276
+ const rest = ["b0", "b1", "b2"].filter((b) => !bands.includes(b));
277
+ const next = rest.length ? `Next: shipvo status, then shipvo promote --to ${rest.join(",")}.` : "Next: shipvo status.";
278
+ return `Published release ${release} to ${bands.join(", ")}. ${next}`;
279
+ }
280
+ /** The rollback result in plain words. */
281
+ export function rolledBackSummary(to, release) {
282
+ return `Rolled back to the code of release ${to} as new release ${release}. Next: shipvo status.`;
240
283
  }
241
284
  /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
242
285
  export function formatSize(bytes) {
243
286
  return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
244
287
  }
245
288
  /** The dry-run result in plain words. `info` says what happened with patches on Android. */
246
- export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff: false, patches: 0 }) {
289
+ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, patches: 0 }) {
247
290
  const lines = [
248
291
  `Dry run complete. Nothing was sent.`,
249
292
  ` Release: ${release}`,
@@ -254,14 +297,32 @@ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff:
254
297
  lines.push(`Patches are not built in a dry run, because older releases are not read. Publish for real to build them.`);
255
298
  else if (info.bases === 0)
256
299
  lines.push(`Full bundles are fine for a first release. Small patches matter once you have older releases to compare against.`);
257
- else if (!info.bsdiff)
258
- lines.push(`Patches were skipped because bsdiff4 is not installed (pip install bsdiff4). Full bundles still work.`);
300
+ else if (info.patches === 0)
301
+ lines.push(`No small patch was built, so this update is a full bundle. Full bundles still work.`);
259
302
  else
260
303
  lines.push(`Patches built: ${info.patches}.`);
261
304
  return lines.join("\n");
262
305
  }
263
- async function publishWeb(kv, cfg, APP, release, outDir) {
264
- execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
306
+ /** The line for a listing file that is not there. It says the file is optional and what to do. */
307
+ export function missingListingNote(file, name) {
308
+ if (name === "icon")
309
+ return `No app icon yet (optional). Add ${file} to show one in the store.`;
310
+ if (name.startsWith("shot-"))
311
+ return `No screenshot yet (optional). Add ${file} to show it in the store.`;
312
+ return `No ${name} yet (optional). Add ${file} to show it in the store.`;
313
+ }
314
+ /** Upload one listing file to the store prefix. A missing file prints a note and returns null. */
315
+ export async function uploadListingFile(kv, store, file, name) {
316
+ if (!existsSync(file)) {
317
+ console.error(missingListingNote(file, name));
318
+ return null;
319
+ }
320
+ const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
321
+ await kv.put(`${store}/${name}${ext}`, new Uint8Array(readFileSync(file)), MIME[ext] ?? "application/octet-stream");
322
+ return `${name}${ext}`;
323
+ }
324
+ async function publishWeb(kv, cfg, APP, release, outDir, io = "inherit") {
325
+ execSync("npx expo export --platform web --output-dir dist-web --clear", { stdio: io, env: { ...process.env, PN_SIM: "1" } });
265
326
  const base = `w/${APP}/r${release}`;
266
327
  const files = [];
267
328
  let indexSha = "";
@@ -293,10 +354,7 @@ async function publishWeb(kv, cfg, APP, release, outDir) {
293
354
  // Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
294
355
  const L = cfg.listing ?? {};
295
356
  const store = `w/${APP}/store`;
296
- const up = async (file, name) => { if (!existsSync(file)) {
297
- console.error(`listing: ${file} not found, skipped`);
298
- return null;
299
- } const ext = file.slice(file.lastIndexOf(".")).toLowerCase(); await kv.put(`${store}/${name}${ext}`, new Uint8Array(readFileSync(file)), MIME[ext] ?? "application/octet-stream"); return `${name}${ext}`; };
357
+ const up = (file, name) => uploadListingFile(kv, store, file, name);
300
358
  const icon = L.icon ? await up(L.icon, "icon") : null;
301
359
  const shots = [];
302
360
  for (const [i, f] of (L.screenshots ?? []).entries()) {
@@ -314,11 +372,23 @@ async function publishWeb(kv, cfg, APP, release, outDir) {
314
372
  return web;
315
373
  }
316
374
  export async function rollback(argv) {
317
- checkFlags(argv, ["to", "bands", "platform", "dry-run"]);
318
- const a = parse(argv), dry = a["dry-run"] !== undefined, cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
375
+ if (argv.includes("--json"))
376
+ return runJson(() => rollbackRun(argv, true), [...DOCTOR, { command: "shipvo status --json", why: "See the newest releases." }]);
377
+ await rollbackRun(argv, false);
378
+ }
379
+ async function rollbackRun(argv, json) {
380
+ checkFlags(argv, ["to", "bands", "platform", "dry-run", "json"]);
381
+ const a = parse(argv), dry = a["dry-run"] !== undefined, cfg = loadConfig();
382
+ const offline = dry && !hasStore();
383
+ const SEED = dry ? "" : seed(), kv = offline ? offlineKv() : kvFor(cfg);
384
+ if (offline)
385
+ console.error("Offline rehearsal: reading releases from .shipvo/dry-run (written by shipvo publish --dry-run). No login used, nothing is sent.");
319
386
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
320
387
  if (!to) {
321
- console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all] [--dry-run]");
388
+ const usage = "Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all] [--dry-run] [--json]";
389
+ if (json)
390
+ throw new UsageError(usage);
391
+ console.error(usage);
322
392
  process.exit(2);
323
393
  }
324
394
  const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
@@ -327,6 +397,8 @@ export async function rollback(argv) {
327
397
  for (const pf of platforms) {
328
398
  const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
329
399
  if (!bytes) {
400
+ if (json)
401
+ throw new CliFailure(`Release ${to} ${pf} bundle not found`, "not_found", 1, [{ command: "shipvo status --json", why: "See which releases exist." }]);
330
402
  console.error(`Release ${to} ${pf} bundle not found`);
331
403
  process.exit(1);
332
404
  }
@@ -344,9 +416,12 @@ export async function rollback(argv) {
344
416
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
345
417
  }
346
418
  }
419
+ const data = { app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at, platforms, bands, dry_run: dry };
347
420
  if (dry) {
348
421
  console.log([`Rollback preview for ${APP}:`, ` Rolling back to the code of release ${to}`, ` New release number: ${release}`, ` Platforms: ${platforms.join(", ")}`, ` Bands: ${bands.join(", ")}`, ` Min shell version: ${minShell}`, `Dry run: nothing was written.`].join("\n"));
349
- return;
422
+ const real = argv.filter((x) => x !== "--dry-run").join(" ");
423
+ return { code: "dry_run", summary: `Rollback preview: release ${to} code becomes release ${release}. Nothing was written.`, data, next: [{ command: `shipvo rollback ${real}`, why: "Do the rollback." }] };
350
424
  }
351
- console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
425
+ console.log(rolledBackSummary(to, release));
426
+ return { code: "rolled_back", summary: `Rolled back to the code of release ${to} as release ${release}.`, data, next: [{ command: "shipvo status --json", why: "Check that phones pick up the rollback." }] };
352
427
  }
package/dist/qr.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export declare const QR_MAX_BYTES = 134;
2
+ /** Reed-Solomon error codewords for `data`. */
3
+ export declare function rsRemainder(data: number[], degree: number): number[];
4
+ /** The 15 format bits for level L and a mask number. */
5
+ export declare function formatBits(mask: number): number;
6
+ /** Encodes text as a QR code. Returns rows of modules, true for dark. Throws when the text is over 134 bytes. */
7
+ export declare function qrMatrix(text: string): boolean[][];
8
+ /** Draws the matrix for a terminal: black on white with ANSI colours, two rows per line, a 2 module quiet zone. */
9
+ export declare function qrToTerminal(matrix: boolean[][], quiet?: number): string;
package/dist/qr.js ADDED
@@ -0,0 +1,213 @@
1
+ // A small QR code encoder: byte mode, error correction level L, versions 1 to 6 (up to 134 bytes). No dependencies.
2
+ // Enough for a LAN URL. `qrMatrix` returns the modules; `qrToTerminal` draws them with half-block characters.
3
+ const SIZES = [
4
+ [19, 7, 1], [34, 10, 1], [55, 15, 1], [80, 20, 1], [108, 26, 1], [136, 18, 2],
5
+ ];
6
+ const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34]];
7
+ export const QR_MAX_BYTES = 134;
8
+ const EXP = new Uint8Array(512);
9
+ const LOG = new Uint8Array(256);
10
+ for (let i = 0, x = 1; i < 255; i++) {
11
+ EXP[i] = x;
12
+ LOG[x] = i;
13
+ x <<= 1;
14
+ if (x & 256)
15
+ x ^= 0x11d;
16
+ }
17
+ for (let i = 255; i < 512; i++)
18
+ EXP[i] = EXP[i - 255];
19
+ const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
20
+ /** Reed-Solomon error codewords for `data`. */
21
+ export function rsRemainder(data, degree) {
22
+ let gen = [1];
23
+ for (let i = 0; i < degree; i++) {
24
+ const next = new Array(gen.length + 1).fill(0);
25
+ gen.forEach((c, j) => { next[j] ^= c; next[j + 1] ^= mul(c, EXP[i]); });
26
+ gen = next;
27
+ }
28
+ const rem = new Array(degree).fill(0);
29
+ for (const b of data) {
30
+ const f = b ^ rem.shift();
31
+ rem.push(0);
32
+ for (let i = 0; i < degree; i++)
33
+ rem[i] ^= mul(gen[i + 1], f);
34
+ }
35
+ return rem;
36
+ }
37
+ function codewordsFor(bytes, version) {
38
+ const [total, ecc, blocks] = SIZES[version - 1];
39
+ const bits = [];
40
+ const put = (v, n) => { for (let i = n - 1; i >= 0; i--)
41
+ bits.push((v >>> i) & 1); };
42
+ put(4, 4);
43
+ put(bytes.length, 8);
44
+ for (const b of bytes)
45
+ put(b, 8);
46
+ put(0, Math.min(4, total * 8 - bits.length));
47
+ while (bits.length % 8)
48
+ bits.push(0);
49
+ const data = [];
50
+ for (let i = 0; i < bits.length; i += 8)
51
+ data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
52
+ for (let pad = 0xec; data.length < total; pad ^= 0xec ^ 0x11)
53
+ data.push(pad);
54
+ const per = total / blocks;
55
+ const dataBlocks = Array.from({ length: blocks }, (_, i) => data.slice(i * per, (i + 1) * per));
56
+ const eccBlocks = dataBlocks.map((b) => rsRemainder(b, ecc));
57
+ const out = [];
58
+ for (let i = 0; i < per; i++)
59
+ for (const b of dataBlocks)
60
+ out.push(b[i]);
61
+ for (let i = 0; i < ecc; i++)
62
+ for (const b of eccBlocks)
63
+ out.push(b[i]);
64
+ return out;
65
+ }
66
+ const MASKS = [
67
+ (x, y) => (x + y) % 2 === 0, (_x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
68
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
69
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => ((((x + y) % 2) + ((x * y) % 3)) % 2) === 0,
70
+ ];
71
+ /** The 15 format bits for level L and a mask number. */
72
+ export function formatBits(mask) {
73
+ const data = (1 << 3) | mask; // level L is 01
74
+ let rem = data;
75
+ for (let i = 0; i < 10; i++)
76
+ rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
77
+ return ((data << 10) | rem) ^ 0x5412;
78
+ }
79
+ function penalty(m) {
80
+ const n = m.length;
81
+ let score = 0;
82
+ let dark = 0;
83
+ const line = (get) => {
84
+ let run = 1;
85
+ for (let i = 1; i <= n; i++) {
86
+ if (i < n && get(i) === get(i - 1))
87
+ run++;
88
+ else {
89
+ if (run >= 5)
90
+ score += run - 2;
91
+ run = 1;
92
+ }
93
+ }
94
+ const s = Array.from({ length: n }, (_, i) => (get(i) ? 1 : 0)).join("");
95
+ for (const p of ["10111010000", "00001011101"])
96
+ for (let i = s.indexOf(p); i >= 0; i = s.indexOf(p, i + 1))
97
+ score += 40;
98
+ };
99
+ for (let a = 0; a < n; a++) {
100
+ line((i) => m[a][i]);
101
+ line((i) => m[i][a]);
102
+ }
103
+ for (let y = 0; y < n; y++)
104
+ for (let x = 0; x < n; x++) {
105
+ if (m[y][x])
106
+ dark++;
107
+ if (x + 1 < n && y + 1 < n && m[y][x] === m[y][x + 1] && m[y][x] === m[y + 1][x] && m[y][x] === m[y + 1][x + 1])
108
+ score += 3;
109
+ }
110
+ return score + (Math.ceil(Math.abs(dark * 20 - n * n * 10) / (n * n)) - 1) * 10;
111
+ }
112
+ /** Encodes text as a QR code. Returns rows of modules, true for dark. Throws when the text is over 134 bytes. */
113
+ export function qrMatrix(text) {
114
+ const bytes = new TextEncoder().encode(text);
115
+ const version = SIZES.findIndex(([total]) => bytes.length <= total - 2) + 1;
116
+ if (!version)
117
+ throw new Error(`QR code input is ${bytes.length} bytes; the limit is ${QR_MAX_BYTES}.`);
118
+ const size = 17 + 4 * version;
119
+ const codewords = codewordsFor(bytes, version);
120
+ const base = Array.from({ length: size }, () => new Array(size).fill(false));
121
+ const fn = Array.from({ length: size }, () => new Array(size).fill(false));
122
+ const set = (x, y, dark) => { if (x >= 0 && y >= 0 && x < size && y < size) {
123
+ base[y][x] = dark;
124
+ fn[y][x] = true;
125
+ } };
126
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
127
+ for (let dy = -4; dy <= 4; dy++)
128
+ for (let dx = -4; dx <= 4; dx++) {
129
+ const d = Math.max(Math.abs(dx), Math.abs(dy));
130
+ set(cx + dx, cy + dy, d !== 4 && d !== 2);
131
+ }
132
+ }
133
+ for (let i = 8; i < size - 8; i++) {
134
+ set(i, 6, i % 2 === 0);
135
+ set(6, i, i % 2 === 0);
136
+ }
137
+ const pos = ALIGN[version - 1];
138
+ for (const cy of pos)
139
+ for (const cx of pos) {
140
+ if ((cx === 6 && cy === 6) || (cx === 6 && cy === size - 7) || (cx === size - 7 && cy === 6))
141
+ continue;
142
+ for (let dy = -2; dy <= 2; dy++)
143
+ for (let dx = -2; dx <= 2; dx++)
144
+ set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
145
+ }
146
+ const format = (mask) => {
147
+ const f = formatBits(mask);
148
+ const bit = (i) => ((f >>> i) & 1) === 1;
149
+ for (let i = 0; i <= 5; i++)
150
+ set(8, i, bit(i));
151
+ set(8, 7, bit(6));
152
+ set(8, 8, bit(7));
153
+ set(7, 8, bit(8));
154
+ for (let i = 9; i < 15; i++)
155
+ set(14 - i, 8, bit(i));
156
+ for (let i = 0; i < 8; i++)
157
+ set(size - 1 - i, 8, bit(i));
158
+ for (let i = 8; i < 15; i++)
159
+ set(8, size - 15 + i, bit(i));
160
+ set(8, size - 8, true);
161
+ };
162
+ format(0); // reserves the areas; redone below with the chosen mask
163
+ let k = 0;
164
+ for (let right = size - 1; right >= 1; right -= 2) {
165
+ if (right === 6)
166
+ right = 5;
167
+ for (let v = 0; v < size; v++)
168
+ for (let j = 0; j < 2; j++) {
169
+ const x = right - j;
170
+ const y = ((right + 1) & 2) === 0 ? size - 1 - v : v;
171
+ if (!fn[y][x] && k < codewords.length * 8) {
172
+ base[y][x] = ((codewords[k >> 3] >>> (7 - (k & 7))) & 1) === 1;
173
+ k++;
174
+ }
175
+ }
176
+ }
177
+ let best;
178
+ let bestScore = Infinity;
179
+ for (let mask = 0; mask < 8; mask++) {
180
+ const m = base.map((row) => row.slice());
181
+ for (let y = 0; y < size; y++)
182
+ for (let x = 0; x < size; x++)
183
+ if (!fn[y][x] && MASKS[mask](x, y))
184
+ m[y][x] = !m[y][x];
185
+ const saveBase = base.map((r) => r.slice());
186
+ for (let y = 0; y < size; y++)
187
+ base[y] = m[y];
188
+ format(mask);
189
+ const s = penalty(base);
190
+ if (s < bestScore) {
191
+ bestScore = s;
192
+ best = base.map((r) => r.slice());
193
+ }
194
+ for (let y = 0; y < size; y++)
195
+ base[y] = saveBase[y];
196
+ }
197
+ return best;
198
+ }
199
+ /** Draws the matrix for a terminal: black on white with ANSI colours, two rows per line, a 2 module quiet zone. */
200
+ export function qrToTerminal(matrix, quiet = 2) {
201
+ const n = matrix.length + quiet * 2;
202
+ const at = (x, y) => matrix[y - quiet]?.[x - quiet] === true;
203
+ const lines = [];
204
+ for (let y = 0; y < n; y += 2) {
205
+ let s = "\x1b[30;47m";
206
+ for (let x = 0; x < n; x++) {
207
+ const t = at(x, y), b = y + 1 < n && at(x, y + 1);
208
+ s += t && b ? "█" : t ? "▀" : b ? "▄" : " ";
209
+ }
210
+ lines.push(s + "\x1b[0m");
211
+ }
212
+ return lines.join("\n");
213
+ }
@@ -0,0 +1,27 @@
1
+ type Step = {
2
+ kind?: string;
3
+ target?: string;
4
+ text?: string;
5
+ enter?: boolean;
6
+ line?: number;
7
+ };
8
+ type Flow = {
9
+ name?: string;
10
+ status?: string;
11
+ steps?: Step[];
12
+ failedStep?: Step | null;
13
+ message?: string | null;
14
+ screenshots?: string[];
15
+ screenshot?: string | null;
16
+ };
17
+ export type TestReport = {
18
+ app?: string | null;
19
+ commit?: string | null;
20
+ release?: string | null;
21
+ time?: string | null;
22
+ parityLimits?: string[];
23
+ flows?: Flow[];
24
+ };
25
+ export declare function stepText(s: Step): string;
26
+ export declare function renderReportMarkdown(report: TestReport): string;
27
+ export {};