@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
package/dist/promote.js CHANGED
@@ -2,11 +2,12 @@
2
2
  // and writes the same release (same bundle, hashes and patches) to the other bands with a fresh issued_at and signature.
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { signBytes } from "./index.js";
5
- import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
5
+ import { CliFailure, UsageError, runJson } from "./json-out.js";
6
+ import { args as parse, hasStore, kv as kvFor, loadConfig, offlineKv, seed } from "./config.js";
6
7
  const BANDS = ["b0", "b1", "b2"];
7
8
  const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
8
9
  const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
9
- export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report"];
10
+ export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report", "json"];
10
11
  export const PROMOTE_HELP = `Usage: shipvo promote [flags]
11
12
 
12
13
  Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
@@ -18,6 +19,7 @@ Flags (write a value after a space, not =):
18
19
  --dry-run Print the plan, write nothing
19
20
  --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)
20
21
  A report for this release with failing flows blocks promote
22
+ --json Print one JSON envelope on stdout; text goes to stderr
21
23
  --force Promote even if the release is halted, has reverts or has a failing test report
22
24
  --help Show this help
23
25
  `;
@@ -26,34 +28,44 @@ function checkFlags(argv, known) {
26
28
  if (bad.length) {
27
29
  const eq = bad.find((x) => x.includes("="));
28
30
  const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
29
- throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
31
+ throw new UsageError(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
30
32
  }
31
33
  }
34
+ const STATUS = [{ command: "shipvo status --json", why: "See how the release does on phones." }];
32
35
  export async function promote(argv, deps = {}) {
33
36
  if (argv.includes("--help") || argv.includes("-h")) {
34
37
  console.log(PROMOTE_HELP);
35
38
  return;
36
39
  }
40
+ if (argv.includes("--json"))
41
+ return runJson(() => promoteRun(argv, deps), [{ command: "shipvo doctor --json", why: "Check your setup." }, ...STATUS]);
42
+ await promoteRun(argv, deps);
43
+ }
44
+ async function promoteRun(argv, deps) {
37
45
  checkFlags(argv, PROMOTE_FLAGS);
38
46
  const a = parse(argv), cfg = loadConfig();
39
47
  const dry = a["dry-run"] !== undefined, force = a.force !== undefined;
40
48
  const p = (a.platform || "android").toLowerCase();
41
49
  if (p !== "android" && p !== "ios" && p !== "all")
42
- throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
50
+ throw new UsageError(`--platform must be android, ios or all (got "${a.platform}")`);
43
51
  const platforms = p === "all" ? ["android", "ios"] : [p];
44
52
  if (a.to === "true")
45
- throw new Error("--to needs bands: pass --to b1,b2");
53
+ throw new UsageError("--to needs bands: pass --to b1,b2");
46
54
  const explicit = a.to ? a.to.split(",").map((s) => s.trim()).filter(Boolean) : null;
47
55
  if (explicit) {
48
56
  const bad = explicit.filter((b) => !BANDS.includes(b));
49
57
  if (bad.length)
50
- throw new Error(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
58
+ throw new UsageError(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
51
59
  }
52
60
  if (a.report === "true")
53
- throw new Error("--report needs a file name: pass --report <file>");
61
+ throw new UsageError("--report needs a file name: pass --report <file>");
54
62
  const reportFile = a.report ?? (existsSync("shipvo-test-report.json") ? "shipvo-test-report.json" : null);
55
63
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
56
- const store = deps.kv ?? kvFor(cfg);
64
+ // A dry run with no login and no Cloudflare keys reads the files `shipvo publish --dry-run` wrote.
65
+ const offline = !deps.kv && dry && !hasStore();
66
+ const store = deps.kv ?? (offline ? offlineKv() : kvFor(cfg));
67
+ if (offline)
68
+ console.log("Offline rehearsal: reading pointers from .shipvo/dry-run (written by shipvo publish --dry-run). No login used, nothing is sent.");
57
69
  // Read every source pointer before touching the network or writing anything.
58
70
  const plans = [];
59
71
  for (const pf of platforms) {
@@ -74,10 +86,10 @@ export async function promote(argv, deps = {}) {
74
86
  catch { /* unreadable pointer: skip */ }
75
87
  }
76
88
  if (!body)
77
- throw new Error(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`);
89
+ throw new CliFailure(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`, "no_pointer", 1, [{ command: "shipvo publish --bands b0 --json", why: "Publish to the first cohort." }]);
78
90
  const release = body.release;
79
91
  if (explicit && explicit.length && explicit.every((b) => b === from))
80
- throw new Error(`${from} is the source band (release ${release}). Pass --to with a different band.`);
92
+ throw new UsageError(`${from} is the source band (release ${release}). Pass --to with a different band.`);
81
93
  // Never write an older release over a newer one: promote widens, it does not downgrade. --force does not change this.
82
94
  const cand = (explicit ?? BANDS).filter((b) => b !== from && (explicit || have.get(b) !== release));
83
95
  const newer = cand.filter((b) => (have.get(b) ?? 0) > release).map((b) => [b, have.get(b)]);
@@ -90,7 +102,7 @@ export async function promote(argv, deps = {}) {
90
102
  rep = JSON.parse(readFileSync(reportFile, "utf8"));
91
103
  }
92
104
  catch (e) {
93
- throw new Error(`Could not read the test report ${reportFile} (${e instanceof Error ? e.message : String(e)}).`);
105
+ throw new CliFailure(`Could not read the test report ${reportFile} (${e instanceof Error ? e.message : String(e)}).`, "report_unreadable", 1, [{ command: "shipvo test --report shipvo-test-report.json --json", why: "Write a fresh test report." }]);
94
106
  }
95
107
  const rel = rep.release == null ? null : String(rep.release);
96
108
  const failing = (Array.isArray(rep.flows) ? rep.flows : []).filter((f) => f.status === "failed").map((f) => f.name ?? "(unnamed flow)");
@@ -98,14 +110,14 @@ export async function promote(argv, deps = {}) {
98
110
  if (mine.length && failing.length) {
99
111
  const msg = `Test report ${reportFile} has ${failing.length} failing flow${failing.length === 1 ? "" : "s"} for release ${mine[0].release}: ${failing.join(", ")}.`;
100
112
  if (!force)
101
- throw new Error(`${msg} Fix them and run shipvo test --report again, or pass --force to promote anyway.`);
113
+ throw new CliFailure(`${msg} Fix them and run shipvo test --report again, or pass --force to promote anyway.`, "tests_failed", 1, [{ command: `shipvo test --report ${reportFile} --json`, why: "Run the flows again after the fix." }]);
102
114
  console.log(`${msg} Overridden by --force.`);
103
115
  }
104
116
  else if (!mine.length) {
105
117
  console.log(`Warning: test report ${reportFile} is for ${rel === null ? "no release" : `release ${rel}`}, not release ${plans.map((x) => x.release).join(", ")}. It is stale and was not used.`);
106
118
  }
107
119
  }
108
- if (!force) {
120
+ if (!force && !offline) {
109
121
  const base = cfg.controlPlane.replace(/\/$/, "");
110
122
  let data;
111
123
  try {
@@ -115,18 +127,20 @@ export async function promote(argv, deps = {}) {
115
127
  data = (await r.json());
116
128
  }
117
129
  catch (e) {
118
- throw new Error(`Could not read the release status from ${base} (${e instanceof Error ? e.message : String(e)}). Try again, or pass --force to promote without the check.`);
130
+ throw new CliFailure(`Could not read the release status from ${base} (${e instanceof Error ? e.message : String(e)}). Try again, or pass --force to promote without the check.`, "remote_failed", 1, [{ command: "shipvo doctor --json", why: "Check the control plane." }]);
119
131
  }
120
132
  for (const { release } of plans) {
121
133
  if (data.halted && typeof data.halted === "object" && data.halted.release === release)
122
- throw new Error(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`);
134
+ throw new CliFailure(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`, "blocked", 1, STATUS);
123
135
  const ev = data.releases?.find((r) => r.release === release)?.events ?? {};
124
136
  const n = REVERT_EVENTS.reduce((s, k) => s + (ev[k] ?? 0), 0);
125
137
  if (n > 0)
126
- throw new Error(`Release ${release} has ${n} reported revert${n === 1 ? "" : "s"}. Check shipvo status, publish a fix, or pass --force to promote it anyway.`);
138
+ throw new CliFailure(`Release ${release} has ${n} reported revert${n === 1 ? "" : "s"}. Check shipvo status, publish a fix, or pass --force to promote it anyway.`, "blocked", 1, STATUS);
127
139
  }
128
140
  }
141
+ const result = [];
129
142
  for (const { pf, release, from, body, targets, newer } of plans) {
143
+ result.push({ platform: pf, release, from, bands: targets, skipped_newer: newer.map(([band, release]) => ({ band, release })) });
130
144
  for (const [b, r] of newer)
131
145
  console.log(`Skipped ${b} (${pf}): it already has release ${r}, which is newer than ${release}.`);
132
146
  if (!targets.length && newer.length) {
@@ -150,4 +164,12 @@ export async function promote(argv, deps = {}) {
150
164
  }
151
165
  if (dry)
152
166
  console.log("Dry run: nothing was written.");
167
+ const count = result.reduce((n, r) => n + r.bands.length, 0);
168
+ const data = { dry_run: dry, promoted: result };
169
+ if (count === 0)
170
+ return { code: "nothing_to_promote", summary: "Nothing to promote: no band needs this release.", data, next: STATUS };
171
+ const list = result.map((r) => `${r.platform} release ${r.release} to ${r.bands.join(", ")}`).join("; ");
172
+ if (dry)
173
+ return { code: "dry_run", summary: `Would promote ${list}. Nothing was written.`, data, next: [{ command: `shipvo promote ${argv.filter((x) => x !== "--dry-run").join(" ")}`.trim(), why: "Do the promote." }] };
174
+ return { code: "promoted", summary: `Promoted ${list}.`, data, next: STATUS };
153
175
  }
package/dist/publish.d.ts CHANGED
@@ -1,11 +1,23 @@
1
- export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
1
+ import path from "node:path";
2
+ /** True when `file` is strictly inside `root`. Uses the platform's separator, so it works with Windows paths too. */
3
+ export declare function isInside(root: string, file: string, p?: typeof path.posix): boolean;
4
+ export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --json Print one JSON envelope on stdout; text and build output go to stderr\n --help Show this help\n";
2
5
  export declare function publish(argv: string[]): Promise<void>;
6
+ /** The publish result in plain words. `--json` carries the full detail. */
7
+ export declare function publishedSummary(release: number, bands: string[]): string;
8
+ /** The rollback result in plain words. */
9
+ export declare function rolledBackSummary(to: number, release: number): string;
3
10
  /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
4
11
  export declare function formatSize(bytes: number): string;
5
12
  /** The dry-run result in plain words. `info` says what happened with patches on Android. */
6
13
  export declare function dryRunSummary(release: number, bytes: number, outDir: string, info?: {
7
14
  bases: number;
8
- bsdiff: boolean;
9
15
  patches: number;
10
16
  }): string;
17
+ /** The line for a listing file that is not there. It says the file is optional and what to do. */
18
+ export declare function missingListingNote(file: string, name: string): string;
19
+ /** Upload one listing file to the store prefix. A missing file prints a note and returns null. */
20
+ export declare function uploadListingFile(kv: {
21
+ put: (k: string, b: BodyInit, m: string) => Promise<unknown>;
22
+ }, store: string, file: string, name: string): Promise<string | null>;
11
23
  export declare function rollback(argv: string[]): Promise<void>;
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", "dry-run-policy", "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.
@@ -91,37 +97,46 @@ Flags (write a value after a space, not =; on/off flags can be bare, or true/fal
91
97
  --dry-run Build and sign, write files locally, send nothing
92
98
  --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing
93
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
94
101
  --help Show this help
95
102
  `;
103
+ const DOCTOR = [{ command: "shipvo doctor --json", why: "Check your setup." }];
96
104
  export async function publish(argv) {
97
105
  if (argv.includes("--help") || argv.includes("-h")) {
98
106
  console.log(PUBLISH_HELP);
99
107
  return;
100
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";
101
116
  checkFlags(argv, PUBLISH_FLAGS);
102
117
  const a = parse(argv), cfg = loadConfig(), SEED = seed();
103
118
  if (switchFlag(a, "dry-run-policy")) {
104
119
  const verdict = checkPublishPolicy(cfg.policy, (a.bands || "b0,b1,b2").split(","));
105
- if (verdict.ok)
106
- console.log(cfg.policy === undefined ? "No policy in shipvo.json: nothing to check." : "Policy allows this publish.");
107
- else {
108
- console.error(`Refused by policy rule ${verdict.rule}: ${verdict.reason}`);
109
- process.exitCode = 1;
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." }] };
110
124
  }
111
- return;
125
+ return refused(verdict, json);
112
126
  }
113
127
  const dry = switchFlag(a, "dry-run"), skipExport = switchFlag(a, "skip-export"), skipWeb = switchFlag(a, "skip-web");
114
128
  if (a.out !== undefined && !dry)
115
- throw new Error("--out only works with --dry-run");
129
+ throw new UsageError("--out only works with --dry-run");
116
130
  if (a.out === "true")
117
- throw new Error("--out needs a directory: pass --out <dir>");
131
+ throw new UsageError("--out needs a directory: pass --out <dir>");
118
132
  const outDir = a.out || ".shipvo/dry-run";
119
133
  const kv = dry ? localKv(outDir) : kvFor(cfg);
120
134
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
121
135
  const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
122
136
  if (a["hermes-bytecode-version"] === "true")
123
- 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)");
124
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
125
140
  const skippedNative = [];
126
141
  const nativeModules = requiredNativeModules(".", skippedNative);
127
142
  if (skippedNative.length)
@@ -131,11 +146,8 @@ export async function publish(argv) {
131
146
  const bands = (a.bands || "b0,b1,b2").split(",");
132
147
  if (dry) {
133
148
  const verdict = checkPublishPolicy(cfg.policy, bands);
134
- if (!verdict.ok) {
135
- console.error(`Refused by policy rule ${verdict.rule}: ${verdict.reason}`);
136
- process.exitCode = 1;
137
- return;
138
- }
149
+ if (!verdict.ok)
150
+ return refused(verdict, json);
139
151
  }
140
152
  const platforms = platformsOf(a);
141
153
  const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
@@ -143,16 +155,16 @@ export async function publish(argv) {
143
155
  if (dry)
144
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)"}.`);
145
157
  let totalBytes = 0;
146
- const patchInfo = { bases: 0, bsdiff: false, patches: 0 };
158
+ const patchInfo = { bases: 0, patches: 0 };
147
159
  for (const pf of platforms) {
148
160
  // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
149
161
  const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
150
162
  if (!skipExport)
151
- 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 });
152
164
  const jsDir = `${root}/_expo/static/js/${pf}`;
153
165
  const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
154
166
  if (!file)
155
- 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." }]);
156
168
  const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
157
169
  const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
158
170
  const name = bundleName(release, pf), newSha = sha256Hex(bytes);
@@ -180,29 +192,23 @@ export async function publish(argv) {
180
192
  }
181
193
  catch { }
182
194
  }
183
- // Only look for bsdiff4 when there is an older bundle to patch from; a first release needs no patches.
184
- 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.
185
197
  if (bases.size > 0) {
186
- try {
187
- execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
188
- bsdiff = true;
189
- }
190
- catch {
191
- 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.");
192
- }
193
- }
194
- if (bsdiff) {
195
- const work = mkdtempSync(join(tmpdir(), "shipvo-"));
196
- writeFileSync(join(work, "new.hbc"), bytes);
197
198
  for (const [fromSha, key] of bases) {
198
199
  if (fromSha === newSha)
199
200
  continue;
200
201
  const base = await kv.getBytes(key);
201
202
  if (!base)
202
203
  continue;
203
- writeFileSync(join(work, "old.hbc"), base);
204
- execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
205
- 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
+ }
206
212
  if (patch.length >= bytes.length * 0.6)
207
213
  continue;
208
214
  const pkey = `b/${APP}/${pf === "ios" ? "ios-" : ""}d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
@@ -211,14 +217,13 @@ export async function publish(argv) {
211
217
  }
212
218
  }
213
219
  patchInfo.bases = bases.size;
214
- patchInfo.bsdiff = bsdiff;
215
220
  patchInfo.patches = patches.length;
216
221
  console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
217
222
  }
218
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).
219
224
  let web = null;
220
225
  if (!skipWeb && pf === platforms[0]) {
221
- web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined);
226
+ web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined, io);
222
227
  }
223
228
  else if (!skipWeb) {
224
229
  try {
@@ -230,10 +235,10 @@ export async function publish(argv) {
230
235
  for (const band of bands) {
231
236
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
232
237
  core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
233
- 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 } : {}) });
234
239
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
235
240
  }
236
- 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");
237
242
  totalBytes += bytes.length;
238
243
  summary[pf] = { bytes: bytes.length, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) };
239
244
  if (web)
@@ -247,14 +252,41 @@ export async function publish(argv) {
247
252
  if (dry)
248
253
  console.log(dryRunSummary(release, totalBytes, outDir, patchInfo));
249
254
  else
250
- 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.`;
251
283
  }
252
284
  /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
253
285
  export function formatSize(bytes) {
254
286
  return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
255
287
  }
256
288
  /** The dry-run result in plain words. `info` says what happened with patches on Android. */
257
- 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 }) {
258
290
  const lines = [
259
291
  `Dry run complete. Nothing was sent.`,
260
292
  ` Release: ${release}`,
@@ -265,14 +297,32 @@ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff:
265
297
  lines.push(`Patches are not built in a dry run, because older releases are not read. Publish for real to build them.`);
266
298
  else if (info.bases === 0)
267
299
  lines.push(`Full bundles are fine for a first release. Small patches matter once you have older releases to compare against.`);
268
- else if (!info.bsdiff)
269
- 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.`);
270
302
  else
271
303
  lines.push(`Patches built: ${info.patches}.`);
272
304
  return lines.join("\n");
273
305
  }
274
- async function publishWeb(kv, cfg, APP, release, outDir) {
275
- 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" } });
276
326
  const base = `w/${APP}/r${release}`;
277
327
  const files = [];
278
328
  let indexSha = "";
@@ -304,10 +354,7 @@ async function publishWeb(kv, cfg, APP, release, outDir) {
304
354
  // Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
305
355
  const L = cfg.listing ?? {};
306
356
  const store = `w/${APP}/store`;
307
- const up = async (file, name) => { if (!existsSync(file)) {
308
- console.error(`listing: ${file} not found, skipped`);
309
- return null;
310
- } 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);
311
358
  const icon = L.icon ? await up(L.icon, "icon") : null;
312
359
  const shots = [];
313
360
  for (const [i, f] of (L.screenshots ?? []).entries()) {
@@ -325,11 +372,23 @@ async function publishWeb(kv, cfg, APP, release, outDir) {
325
372
  return web;
326
373
  }
327
374
  export async function rollback(argv) {
328
- checkFlags(argv, ["to", "bands", "platform", "dry-run"]);
329
- 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.");
330
386
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
331
387
  if (!to) {
332
- 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);
333
392
  process.exit(2);
334
393
  }
335
394
  const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
@@ -338,6 +397,8 @@ export async function rollback(argv) {
338
397
  for (const pf of platforms) {
339
398
  const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
340
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." }]);
341
402
  console.error(`Release ${to} ${pf} bundle not found`);
342
403
  process.exit(1);
343
404
  }
@@ -355,9 +416,12 @@ export async function rollback(argv) {
355
416
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
356
417
  }
357
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 };
358
420
  if (dry) {
359
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"));
360
- 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." }] };
361
424
  }
362
- 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." }] };
363
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;