@shipvo/cli 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ npx -y @shipvo/cli sim # serve the app in the simulator for age
26
26
  | `mcp` | Print the MCP config agents need |
27
27
  | `keygen [--out seed.txt]` | Create an Ed25519 release signing key |
28
28
  | `pubkey` | Print the public key for the current seed |
29
- | `hash <file>`, `sign-file <file>`, `sign-json <file>`, `verify-json <file> <key>` | Low-level signing helpers |
29
+ | `hash <file>`, `sign-file <file>`, `sign-json <file>`, `verify-json <file> <key>`, `verify <pointer> [key]` (release pointers) | Low-level signing helpers |
30
30
 
31
31
  Changing computers? See [Moving to a new machine](new-machine.md).
32
32
 
package/dist/cli.js CHANGED
@@ -7,29 +7,12 @@ import { init } from "./init.js";
7
7
  import { publish, rollback } from "./publish.js";
8
8
  import { sim } from "./sim.js";
9
9
  import { keys, login, mcp } from "./login.js";
10
+ import { verifyPointer } from "./verify.js";
10
11
  import { signingKey } from "./signing.js";
11
12
  import { appCmd } from "./app.js";
13
+ import { doctor } from "./doctor.js";
14
+ import { help, unknownCommand } from "./help.js";
12
15
  const [cmd, ...rest] = process.argv.slice(2);
13
- const help = `shipvo <command>
14
-
15
- init <dir> New app wired to Shipvo: OTA runtime, device API, simulator, publish workflow
16
- sim [--port 4545] Build the app for the simulator and serve it (agents: open_app("<appId>"))
17
- publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release (+ the agent-facing web artifact)
18
- rollback --to <release> Republish an earlier release's code as a new release
19
- app create | list | delete Register this app with the hosted control plane (publish token), list yours, or remove one
20
- login Sign in with GitHub and get a key for the hosted simulator
21
- keys Show the stored key's status and quota
22
- keys export [--out f] Print (or save) this app's release signing seed, to back it up or move machines
23
- keys import [seed|--file f] Restore the signing seed on a new machine (checked against shipvo.json)
24
- mcp Print the MCP config agents need (hosted and local)
25
-
26
- keygen [--out seed.txt] Create an Ed25519 release signing key; prints the public key
27
- pubkey Print the public key for the seed in SHIPVO_SIGNING_SEED
28
- hash <file> SHA-256 and size of a bundle
29
- sign-file <file> Ed25519 signature (base64) over a file's bytes
30
- sign-json <file.json> Sign a JSON document (canonical form); writes signature into it
31
- verify-json <file.json> <public-key-b64>
32
- `;
33
16
  switch (cmd) {
34
17
  case "init":
35
18
  await init(rest);
@@ -46,6 +29,9 @@ switch (cmd) {
46
29
  case "login":
47
30
  await login(rest);
48
31
  break;
32
+ case "doctor":
33
+ await doctor(rest);
34
+ break;
49
35
  case "app":
50
36
  await appCmd(rest);
51
37
  break;
@@ -92,7 +78,31 @@ switch (cmd) {
92
78
  console.log(ok ? "valid" : "INVALID");
93
79
  process.exit(ok ? 0 : 1);
94
80
  }
95
- default:
81
+ case "verify": {
82
+ if (!rest[0]) {
83
+ console.error("Usage: shipvo verify <pointer file> [public-key-b64]");
84
+ process.exit(2);
85
+ }
86
+ const key = rest[1] ?? b64(keyFromSeed(seed()).publicKey);
87
+ let text;
88
+ try {
89
+ text = readFileSync(rest[0], "utf8");
90
+ }
91
+ catch {
92
+ console.error(`Cannot read ${rest[0]}`);
93
+ process.exit(2);
94
+ }
95
+ const ok = verifyPointer(text, key);
96
+ console.log(ok ? "valid" : "INVALID");
97
+ process.exit(ok ? 0 : 1);
98
+ }
99
+ case undefined:
100
+ case "--help":
101
+ case "-h":
102
+ case "help":
96
103
  console.log(help);
97
- process.exit(cmd ? 1 : 0);
104
+ break;
105
+ default:
106
+ console.error(unknownCommand(cmd));
107
+ process.exit(1);
98
108
  }
package/dist/config.d.ts CHANGED
@@ -7,6 +7,8 @@ export type ShipvoConfig = {
7
7
  controlPlane: string;
8
8
  /** Base64 Ed25519 public key built into the shell. */
9
9
  publicKey: string;
10
+ /** Hermes bytecode version(s) the app's shells run. If set, `shipvo publish` refuses bundles that differ. */
11
+ hermesBytecodeVersion?: number | number[];
10
12
  /** Where `shipvo publish` writes: the control plane's Cloudflare KV namespace. */
11
13
  kv?: {
12
14
  namespaceId: string;
@@ -0,0 +1,17 @@
1
+ import { existsSync } from "node:fs";
2
+ type CheckResult = {
3
+ name: string;
4
+ ok: boolean;
5
+ fix: string;
6
+ };
7
+ type DoctorDeps = {
8
+ cwd: string;
9
+ env: NodeJS.ProcessEnv;
10
+ nodeVersion: string;
11
+ fetchFn: typeof fetch;
12
+ fileExists: typeof existsSync;
13
+ readText: (path: string) => string;
14
+ };
15
+ export declare function runDoctorChecks(overrides?: Partial<DoctorDeps>): Promise<CheckResult[]>;
16
+ export declare function doctor(argv: string[]): Promise<void>;
17
+ export {};
package/dist/doctor.js ADDED
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { b64, keyFromSeed } from "./index.js";
4
+ import { parseDeclared } from "./hermes.js";
5
+ const defaultDeps = () => ({
6
+ cwd: process.cwd(),
7
+ env: process.env,
8
+ nodeVersion: process.versions.node,
9
+ fetchFn: fetch,
10
+ fileExists: existsSync,
11
+ readText: (path) => readFileSync(path, "utf8"),
12
+ });
13
+ const parseMajor = (v) => Number(v.split(".")[0] || 0);
14
+ const pub = (s) => b64(keyFromSeed(s).publicKey);
15
+ function resolveSeed(env, cwd, readText) {
16
+ const direct = env.SHIPVO_SIGNING_SEED || env.PURENATIVE_SIGNING_SEED;
17
+ if (direct)
18
+ return direct;
19
+ const file = env.SHIPVO_SIGNING_SEED_FILE || env.PURENATIVE_SIGNING_SEED_FILE;
20
+ if (file) {
21
+ try {
22
+ return readText(file).trim() || null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ try {
29
+ return readText(join(cwd, ".shipvo", "seed.txt")).trim() || null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function loadShipvoConfig(deps) {
36
+ const path = join(deps.cwd, "shipvo.json");
37
+ if (!deps.fileExists(path)) {
38
+ return { cfg: null, parseCheck: { name: "shipvo.json parses", ok: false, fix: "run this in your app root (or create shipvo.json with `shipvo init`)" } };
39
+ }
40
+ try {
41
+ const cfg = JSON.parse(deps.readText(path));
42
+ return { cfg, parseCheck: { name: "shipvo.json parses", ok: true, fix: "none" } };
43
+ }
44
+ catch {
45
+ return { cfg: null, parseCheck: { name: "shipvo.json parses", ok: false, fix: "fix JSON syntax in shipvo.json" } };
46
+ }
47
+ }
48
+ export async function runDoctorChecks(overrides = {}) {
49
+ const deps = { ...defaultDeps(), ...overrides };
50
+ const results = [];
51
+ const nodeMajor = parseMajor(deps.nodeVersion);
52
+ results.push({
53
+ name: "Node version (>=22)",
54
+ ok: Number.isInteger(nodeMajor) && nodeMajor >= 22,
55
+ fix: "use Node 22 or newer (for example: `nvm use 22`)",
56
+ });
57
+ const { cfg, parseCheck } = loadShipvoConfig(deps);
58
+ results.push(parseCheck);
59
+ const signingOk = (() => {
60
+ if (!cfg?.publicKey || typeof cfg.publicKey !== "string")
61
+ return false;
62
+ const s = resolveSeed(deps.env, deps.cwd, deps.readText);
63
+ if (!s)
64
+ return false;
65
+ try {
66
+ return pub(s) === cfg.publicKey;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ })();
72
+ const needCfg = "fix shipvo.json first";
73
+ results.push({ name: "signing key matches shipvo.json publicKey", ok: signingOk, fix: !cfg ? needCfg : "restore the right seed with `shipvo keys import` (or set SHIPVO_SIGNING_SEED)" });
74
+ let controlPlaneOk = false;
75
+ let cpFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
76
+ if (cfg?.controlPlane && typeof cfg.controlPlane === "string") {
77
+ try {
78
+ const res = await deps.fetchFn(cfg.controlPlane.replace(/\/$/, ""), { signal: AbortSignal.timeout(5000) });
79
+ if (res.status >= 500)
80
+ cpFix = `the control plane answered HTTP ${res.status}; check its deploy and logs`;
81
+ else
82
+ controlPlaneOk = true;
83
+ }
84
+ catch { }
85
+ }
86
+ results.push({ name: "control plane reachable", ok: controlPlaneOk, fix: !cfg ? needCfg : cpFix });
87
+ let hermesOk = true;
88
+ if (cfg && Object.prototype.hasOwnProperty.call(cfg, "hermesBytecodeVersion")) {
89
+ try {
90
+ parseDeclared(cfg.hermesBytecodeVersion); // undefined means "not declared", which passes
91
+ }
92
+ catch {
93
+ hermesOk = false;
94
+ }
95
+ }
96
+ results.push({ name: "hermesBytecodeVersion (if present) is set", ok: hermesOk, fix: "set hermesBytecodeVersion to a positive integer or list (for example 96 or [96, 98])" });
97
+ return results;
98
+ }
99
+ export async function doctor(argv) {
100
+ if (argv.length) {
101
+ console.error("Usage: shipvo doctor");
102
+ process.exit(2);
103
+ }
104
+ const results = await runDoctorChecks();
105
+ for (const r of results)
106
+ console.log(`${r.ok ? `PASS ${r.name}` : `FAIL ${r.name} — fix: ${r.fix}`}`);
107
+ if (results.some((r) => !r.ok))
108
+ process.exit(1);
109
+ }
package/dist/help.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare const COMMANDS: readonly ["init", "sim", "publish", "rollback", "app", "login", "doctor", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
2
+ export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n\nShip\n publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n rollback --to <release> Republish an earlier release as a new release\n app create | list | delete Register, list or remove apps on the hosted control plane\n login Sign in with GitHub and get a key for the hosted simulator\n doctor Check setup before first publish\n keys Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
3
+ export declare function distance(a: string, b: string): number;
4
+ /** Closest command within edit distance 2, or undefined. */
5
+ export declare function suggest(input: string): string | undefined;
6
+ export declare function unknownCommand(input: string): string;
package/dist/help.js ADDED
@@ -0,0 +1,61 @@
1
+ export const COMMANDS = [
2
+ "init", "sim", "publish", "rollback", "app", "login", "doctor", "keys", "mcp",
3
+ "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify",
4
+ ];
5
+ export const help = `shipvo <command>
6
+
7
+ Start
8
+ init <dir> Create a new app wired to Shipvo
9
+ sim [--port 4545] Build the app and run it in the simulator
10
+
11
+ Ship
12
+ publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release
13
+ publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote
14
+ rollback --to <release> Republish an earlier release as a new release
15
+ app create | list | delete Register, list or remove apps on the hosted control plane
16
+ login Sign in with GitHub and get a key for the hosted simulator
17
+ doctor Check setup before first publish
18
+ keys Show the stored key's status and quota
19
+ keys export [--out f] Print or save this app's signing seed, to back it up
20
+ keys import [seed|--file f] Restore the signing seed on a new machine
21
+ mcp Print the MCP config that agents need
22
+
23
+ Advanced
24
+ keygen [--out seed.txt] Create an Ed25519 signing key and print the public key
25
+ pubkey Print the public key for SHIPVO_SIGNING_SEED
26
+ hash <file> Print the SHA-256 and size of a file
27
+ sign-file <file> Print the Ed25519 signature (base64) of a file
28
+ sign-json <file.json> Sign a JSON document and write the signature into it
29
+ verify <pointer> [public-key-b64]
30
+ Check a release pointer (such as one from publish --dry-run)
31
+ verify-json <file.json> <public-key-b64>
32
+ Check the signature of a JSON document
33
+ `;
34
+ export function distance(a, b) {
35
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
36
+ for (let i = 1; i <= a.length; i++) {
37
+ const cur = [i];
38
+ for (let j = 1; j <= b.length; j++) {
39
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
40
+ }
41
+ prev = cur;
42
+ }
43
+ return prev[b.length];
44
+ }
45
+ /** Closest command within edit distance 2, or undefined. */
46
+ export function suggest(input) {
47
+ let best;
48
+ let bestD = 3;
49
+ for (const c of COMMANDS) {
50
+ const d = distance(input.toLowerCase(), c);
51
+ if (d < bestD) {
52
+ best = c;
53
+ bestD = d;
54
+ }
55
+ }
56
+ return best;
57
+ }
58
+ export function unknownCommand(input) {
59
+ const s = suggest(input);
60
+ return `shipvo: '${input}' is not a command.${s ? ` Did you mean '${s}'?` : ""}\nRun 'shipvo --help' to see all commands.\n`;
61
+ }
@@ -0,0 +1,11 @@
1
+ /** True if the bytes start with the Hermes bytecode magic number. */
2
+ export declare const isHermesBytecode: (b: Uint8Array) => boolean;
3
+ /** The bytecode version in the header, or null if this is not Hermes bytecode. */
4
+ export declare function hermesBytecodeVersion(b: Uint8Array): number | null;
5
+ /** Parse a declared version list such as "96" or "96,98" (CLI flag) or a number / number[] (shipvo.json). */
6
+ export declare function parseDeclared(v: unknown): number[] | undefined;
7
+ /**
8
+ * Decide what to record in the release. Returns the version (or undefined when nothing is known).
9
+ * Throws when the bundle is not bytecode but the app declares a bytecode version, or when the versions differ.
10
+ */
11
+ export declare function checkBundle(file: string, bytes: Uint8Array, declared?: number[]): number | undefined;
package/dist/hermes.js ADDED
@@ -0,0 +1,41 @@
1
+ // Read the Hermes bytecode version from a bundle header, and check it against what the app's shells declare.
2
+ // A Hermes bytecode file starts with an 8-byte magic number (little-endian 0x1F1903C103BC1FC6),
3
+ // followed by the bytecode format version as a 32-bit little-endian integer.
4
+ // Layout source: the BytecodeFileHeader struct in facebook/hermes, include/hermes/BCGen/HBC/BytecodeFileFormat.h
5
+ // (fields MAGIC, then VERSION).
6
+ const MAGIC = [0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f];
7
+ /** True if the bytes start with the Hermes bytecode magic number. */
8
+ export const isHermesBytecode = (b) => b.length >= 12 && MAGIC.every((m, i) => b[i] === m);
9
+ /** The bytecode version in the header, or null if this is not Hermes bytecode. */
10
+ export function hermesBytecodeVersion(b) {
11
+ if (!isHermesBytecode(b))
12
+ return null;
13
+ return new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(8, true);
14
+ }
15
+ /** Parse a declared version list such as "96" or "96,98" (CLI flag) or a number / number[] (shipvo.json). */
16
+ export function parseDeclared(v) {
17
+ if (v === undefined || v === null || v === "")
18
+ return undefined;
19
+ const list = Array.isArray(v) ? v : String(v).split(",");
20
+ const nums = list.map((x) => Number(String(x).trim()));
21
+ if (!nums.length || nums.some((n) => !Number.isInteger(n) || n <= 0))
22
+ throw new Error(`hermes bytecode version must be positive integers (got ${JSON.stringify(v)})`);
23
+ return nums;
24
+ }
25
+ /**
26
+ * Decide what to record in the release. Returns the version (or undefined when nothing is known).
27
+ * Throws when the bundle is not bytecode but the app declares a bytecode version, or when the versions differ.
28
+ */
29
+ export function checkBundle(file, bytes, declared) {
30
+ const version = hermesBytecodeVersion(bytes);
31
+ if (version === null) {
32
+ if (file.endsWith(".hbc"))
33
+ throw new Error(`${file} has a .hbc name but no Hermes bytecode header. Was it compiled with hermesc?`);
34
+ if (declared)
35
+ throw new Error(`${file} is plain JavaScript, but the app declares Hermes bytecode version ${declared.join(", ")}. Enable Hermes bytecode in the export, or remove hermesBytecodeVersion.`);
36
+ return undefined;
37
+ }
38
+ if (declared && !declared.includes(version))
39
+ throw new Error(`${file} is Hermes bytecode version ${version}, but the app's shells run ${declared.join(", ")}. Build with the same Hermes as the shell, or update hermesBytecodeVersion if the shell changed.`);
40
+ return version;
41
+ }
package/dist/init.js CHANGED
@@ -68,7 +68,7 @@ module.exports = withShipvoSim(getDefaultConfig(__dirname));
68
68
  w("index.ts", `import { registerRootComponent } from "expo";\nimport App from "./App";\nregisterRootComponent(App);\n`);
69
69
  w(".gitignore", `node_modules/\n.expo/\ndist/\ndist-ota/\ndist-web/\nsim-dist/\nweb-build/\n/ios\n/android\n.kotlin/\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n*.tsbuildinfo\n.env*.local\n.DS_Store\n# never commit the release signing seed\n.shipvo/\n`);
70
70
  w("src/release.ts", `// Edit and push: the publish workflow signs and ships this to every installed phone and to the simulator.
71
- export const RELEASE = { headline: "Release 1: hello from ${name}.", note: "Edit src/release.ts and run \`shipvo publish\`." };
71
+ export const RELEASE = { headline: "Hello from ${name}.", note: "Edit src/release.ts and run \`shipvo publish\`." };
72
72
  `);
73
73
  w("App.tsx", APP_TSX(name, appId));
74
74
  w(".github/workflows/publish.yml", WORKFLOW);
@@ -111,7 +111,7 @@ export default function App() {
111
111
 
112
112
  // Over-the-air updates: tell the runtime the first screen rendered (so this release is kept), then poll while open.
113
113
  useEffect(() => {
114
- try { const h = Runtime.markHealthy(); add(\`Running release \${h.release}\`); } catch {}
114
+ try { const h = Runtime.markHealthy(); add(h.release ? \`Running release \${h.release}\` : "Running local build"); } catch {}
115
115
  const check = async () => {
116
116
  if (busy.current || !state?.baseUrl) return; busy.current = true;
117
117
  try { const r = await Runtime.checkForUpdate(true); if (r.status === "staged") add(\`Applied release \${r.release} (\${r.via ?? "full"})\`); }
@@ -140,7 +140,7 @@ export default function App() {
140
140
  <Btn label="Send a notification" onPress={notify} />
141
141
  <Text style={s.h2} accessibilityRole="header">Log</Text>
142
142
  {log.length === 0 ? <Text style={s.muted}>Nothing yet.</Text> : log.map((l, i) => <Text key={i} style={s.line}>{l}</Text>)}
143
- <Text style={s.muted}>App id ${appId} · shell {state?.shellVersion ?? "none"} · release {state?.runningRelease ?? 0}</Text>
143
+ <Text style={s.muted}>App id ${appId} · shell {state?.shellVersion ?? "none"} · {state?.runningRelease ? \`release \${state.runningRelease}\` : "local build"}</Text>
144
144
  </ScrollView>
145
145
  <ScanHost accent="#1F4E5F" />
146
146
  </View>
@@ -194,8 +194,8 @@ const TEST_APP = (name) => `// Tests that run your app in the Shipvo simulator:
194
194
  // Run them with "npm test" (start the simulator first: npm run sim) and in CI (.github/workflows/test.yml).
195
195
  import { test, expect } from "@shipvo/test/vitest";
196
196
 
197
- test("the app opens and shows its release", async ({ phone }) => {
198
- expect(phone).toSee(/Release \\d+/);
197
+ test("the app opens and shows its headline", async ({ phone }) => {
198
+ expect(phone).toSee("Hello from ${name}.");
199
199
  expect(phone.screen).not.toMatch(/something went wrong|unhandled|exception/i);
200
200
  });
201
201
 
package/dist/login.js CHANGED
@@ -65,11 +65,14 @@ export async function keys() {
65
65
  export function mcp() {
66
66
  const c = savedCreds();
67
67
  if (!c) {
68
- console.error("Not signed in. Run `shipvo login`.");
69
- process.exit(1);
68
+ printLocalMcp();
69
+ console.log("\nTo let agents open your published app from anywhere, run `shipvo login` for a hosted config.");
70
+ return;
70
71
  }
71
72
  printMcp(c.mcp_url, c.key);
72
73
  }
74
+ const LOCAL_MCP = `Local simulator (no login needed). Run \`npm run sim\` first:\n${JSON.stringify({ mcpServers: { shipvo: { command: "npx", args: ["-y", "@shipvo/mcp"] } } }, null, 2)}`;
75
+ function printLocalMcp() { console.log(LOCAL_MCP); }
73
76
  function printMcp(url, key) {
74
77
  console.log(`Claude Desktop / Cursor / Claude Code (remote):\n${JSON.stringify({ mcpServers: { shipvo: { url, headers: { Authorization: `Bearer ${key}` } } } }, null, 2)}\n`);
75
78
  console.log(`ChatGPT (custom MCP connector, cannot send headers): ${url}/${key}\n`);
package/dist/publish.d.ts CHANGED
@@ -1,2 +1,11 @@
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 =):\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 true Reuse the last build in dist-ota/\n --skip-web true Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
1
2
  export declare function publish(argv: string[]): Promise<void>;
3
+ /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
4
+ export declare function formatSize(bytes: number): string;
5
+ /** The dry-run result in plain words. `info` says what happened with patches on Android. */
6
+ export declare function dryRunSummary(release: number, bytes: number, outDir: string, info?: {
7
+ bases: number;
8
+ bsdiff: boolean;
9
+ patches: number;
10
+ }): string;
2
11
  export declare function rollback(argv: string[]): Promise<void>;
package/dist/publish.js CHANGED
@@ -1,10 +1,11 @@
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, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { join } from "node:path";
6
+ import { dirname, join, resolve } from "node:path";
7
7
  import { sha256Hex, signBytes } from "./index.js";
8
+ import { checkBundle, parseDeclared } from "./hermes.js";
8
9
  import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
9
10
  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" };
10
11
  const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
@@ -33,14 +34,80 @@ const platformsOf = (a) => {
33
34
  return [p];
34
35
  throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
35
36
  };
37
+ /** Unknown flags are errors: a typo like --dryrun must not fall through to a real publish. */
38
+ function checkFlags(argv, known) {
39
+ const bad = argv.filter((x) => x.startsWith("--") && !known.includes(x.slice(2)));
40
+ if (bad.length) {
41
+ const eq = bad.find((x) => x.includes("="));
42
+ const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
43
+ throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
44
+ }
45
+ }
46
+ /** 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. */
47
+ function localKv(dir) {
48
+ const root = resolve(dir);
49
+ const mem = new Map();
50
+ const enc = new TextEncoder();
51
+ return {
52
+ async get(k) { const v = mem.get(k); return v ? new TextDecoder().decode(v) : null; },
53
+ async getBytes(k) { return mem.get(k) ?? null; },
54
+ async keys(prefix) { return [...mem.keys()].filter((k) => k.startsWith(prefix)); },
55
+ async put(k, body, _type) {
56
+ const f = resolve(root, k);
57
+ if (!f.startsWith(root + "/"))
58
+ throw new Error(`refusing to write outside ${dir}: ${k}`);
59
+ const bytes = typeof body === "string" ? enc.encode(body) : new Uint8Array(body);
60
+ mkdirSync(dirname(f), { recursive: true });
61
+ writeFileSync(f, bytes);
62
+ mem.set(k, bytes);
63
+ },
64
+ };
65
+ }
66
+ const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "out"];
67
+ export const PUBLISH_HELP = `Usage: shipvo publish [flags]
68
+
69
+ Build, sign and publish an over-the-air release.
70
+
71
+ Flags (write a value after a space, not =):
72
+ --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)
73
+ --platform android|ios|all Platform to publish (default android)
74
+ --release N Release number (default: the next one)
75
+ --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)
76
+ --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98
77
+ --max-bases N How many older releases to build patches from (default 6)
78
+ --skip-export true Reuse the last build in dist-ota/
79
+ --skip-web true Do not publish the web copy for the simulator
80
+ --dry-run Build and sign, write files locally, send nothing
81
+ --out <dir> Where --dry-run writes files (default .shipvo/dry-run)
82
+ --help Show this help
83
+ `;
36
84
  export async function publish(argv) {
37
- const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
85
+ if (argv.includes("--help") || argv.includes("-h")) {
86
+ console.log(PUBLISH_HELP);
87
+ return;
88
+ }
89
+ checkFlags(argv, PUBLISH_FLAGS);
90
+ const a = parse(argv), cfg = loadConfig(), SEED = seed();
91
+ const dry = a["dry-run"] !== undefined; // a plain switch: any use turns it on
92
+ if (a.out !== undefined && !dry)
93
+ throw new Error("--out only works with --dry-run");
94
+ if (a.out === "true")
95
+ throw new Error("--out needs a directory: pass --out <dir>");
96
+ const outDir = a.out || ".shipvo/dry-run";
97
+ const kv = dry ? localKv(outDir) : kvFor(cfg);
38
98
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
39
99
  const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
100
+ if (a["hermes-bytecode-version"] === "true")
101
+ throw new Error("--hermes-bytecode-version needs a value: pass a value like 96 (or 96,98)");
102
+ const declaredHermes = parseDeclared(a["hermes-bytecode-version"] ?? cfg.hermesBytecodeVersion);
40
103
  const bands = (a.bands || "b0,b1,b2").split(",");
41
104
  const platforms = platformsOf(a);
42
105
  const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
43
- const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL };
106
+ const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL, ...(dry ? { dry_run: true, out: outDir } : {}) };
107
+ if (dry)
108
+ 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)"}.`);
109
+ let totalBytes = 0;
110
+ const patchInfo = { bases: 0, bsdiff: false, patches: 0 };
44
111
  for (const pf of platforms) {
45
112
  // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
46
113
  const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
@@ -51,15 +118,16 @@ export async function publish(argv) {
51
118
  if (!file)
52
119
  throw new Error("no bundle in " + jsDir);
53
120
  const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
121
+ const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
54
122
  const name = bundleName(release, pf), newSha = sha256Hex(bytes);
55
123
  await kv.put(`b/${APP}/${name}`, bytes, "application/octet-stream");
56
- // Patches (Android): from recent releases and each shell's embedded bundle; devices verify the patched result against the signed sha256.
57
- // iOS ships full bundles for now: the iOS runtime does not apply patches yet.
124
+ // Patches: from recent releases (and, on Android, each shell's embedded bundle); devices verify the patched result against the signed sha256.
125
+ // iOS patches only from earlier iOS releases: the iOS runtime has no embedded-bundle baseline yet.
58
126
  const patches = [];
59
- if (pf === "android") {
127
+ {
60
128
  const MAX_BASES = Number(a["max-bases"] || 6);
61
129
  const bases = new Map();
62
- const hist = (await Promise.all((await kv.keys(`h/${APP}/r`)).map(async (k) => { try {
130
+ const hist = (await Promise.all((await kv.keys(pf === "ios" ? `h/${APP}/ios-r` : `h/${APP}/r`)).map(async (k) => { try {
63
131
  return JSON.parse((await kv.get(k)));
64
132
  }
65
133
  catch {
@@ -67,21 +135,25 @@ export async function publish(argv) {
67
135
  } })))
68
136
  .filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
69
137
  for (const h of hist)
70
- bases.set(h.sha256, `b/${APP}/r${h.release}.hbc`);
71
- for (const k of await kv.keys(`base/${APP}/`)) {
138
+ bases.set(h.sha256, `b/${APP}/${bundleName(h.release, pf)}`);
139
+ if (pf === "android")
140
+ for (const k of await kv.keys(`base/${APP}/`)) {
141
+ try {
142
+ const b = JSON.parse((await kv.get(k)));
143
+ bases.set(b.sha256, b.key);
144
+ }
145
+ catch { }
146
+ }
147
+ // Only look for bsdiff4 when there is an older bundle to patch from; a first release needs no patches.
148
+ let bsdiff = false;
149
+ if (bases.size > 0) {
72
150
  try {
73
- const b = JSON.parse((await kv.get(k)));
74
- bases.set(b.sha256, b.key);
151
+ execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
152
+ bsdiff = true;
153
+ }
154
+ catch {
155
+ 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.");
75
156
  }
76
- catch { }
77
- }
78
- let bsdiff = true;
79
- try {
80
- execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
81
- }
82
- catch {
83
- bsdiff = false;
84
- console.error("bsdiff4 not installed (pip install bsdiff4): publishing full bundles only");
85
157
  }
86
158
  if (bsdiff) {
87
159
  const work = mkdtempSync(join(tmpdir(), "shipvo-"));
@@ -97,11 +169,14 @@ export async function publish(argv) {
97
169
  const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
98
170
  if (patch.length >= bytes.length * 0.6)
99
171
  continue;
100
- const pkey = `b/${APP}/d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
172
+ const pkey = `b/${APP}/${pf === "ios" ? "ios-" : ""}d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
101
173
  await kv.put(pkey, patch, "application/octet-stream");
102
174
  patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
103
175
  }
104
176
  }
177
+ patchInfo.bases = bases.size;
178
+ patchInfo.bsdiff = bsdiff;
179
+ patchInfo.patches = patches.length;
105
180
  console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
106
181
  }
107
182
  // The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator (built once, with the first platform).
@@ -119,11 +194,12 @@ export async function publish(argv) {
119
194
  for (const band of bands) {
120
195
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
121
196
  core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
122
- addons: [], screens: [], min_shell_version: MIN_SHELL, ...(web ? { web } : {}) });
197
+ addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(web ? { web } : {}) });
123
198
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
124
199
  }
125
- 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, web }), "application/json");
126
- summary[pf] = { bytes: bytes.length, ...(pf === "android" ? { patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) } : {}) };
200
+ 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 } : {}), web }), "application/json");
201
+ totalBytes += bytes.length;
202
+ summary[pf] = { bytes: bytes.length, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) };
127
203
  if (web)
128
204
  summary.web = web;
129
205
  }
@@ -132,7 +208,32 @@ export async function publish(argv) {
132
208
  Object.assign(summary, { bytes: an.bytes, patches: an.patches });
133
209
  delete summary.android;
134
210
  }
135
- console.log(JSON.stringify(summary));
211
+ if (dry)
212
+ console.log(dryRunSummary(release, totalBytes, outDir, patchInfo));
213
+ else
214
+ console.log(JSON.stringify(summary));
215
+ }
216
+ /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
217
+ export function formatSize(bytes) {
218
+ return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
219
+ }
220
+ /** The dry-run result in plain words. `info` says what happened with patches on Android. */
221
+ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff: false, patches: 0 }) {
222
+ const lines = [
223
+ `Dry run complete. Nothing was sent.`,
224
+ ` Release: ${release}`,
225
+ ` Size: ${formatSize(bytes)}`,
226
+ ` Files: ${outDir}`,
227
+ ];
228
+ if (info.bases === 0 && release > 1)
229
+ lines.push(`Patches are not built in a dry run, because older releases are not read. Publish for real to build them.`);
230
+ else if (info.bases === 0)
231
+ lines.push(`Full bundles are fine for a first release. Small patches matter once you have older releases to compare against.`);
232
+ else if (!info.bsdiff)
233
+ lines.push(`Patches were skipped because bsdiff4 is not installed (pip install bsdiff4). Full bundles still work.`);
234
+ else
235
+ lines.push(`Patches built: ${info.patches}.`);
236
+ return lines.join("\n");
136
237
  }
137
238
  async function publishWeb(kv, cfg, APP, release) {
138
239
  execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
@@ -186,6 +287,7 @@ async function publishWeb(kv, cfg, APP, release) {
186
287
  return web;
187
288
  }
188
289
  export async function rollback(argv) {
290
+ checkFlags(argv, ["to", "bands", "platform"]);
189
291
  const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
190
292
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
191
293
  if (!to) {
package/dist/sim.js CHANGED
@@ -2,10 +2,37 @@
2
2
  import { execSync, spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { createRequire } from "node:module";
5
+ import { createServer, connect } from "node:net";
5
6
  import { args as parse, loadConfig } from "./config.js";
7
+ /** True if nothing is listening on the port (checks by binding, then closing). */
8
+ function portFree(port) {
9
+ return new Promise((resolve) => {
10
+ const s = createServer();
11
+ s.once("error", () => resolve(false));
12
+ s.listen(port, () => s.close(() => resolve(true)));
13
+ });
14
+ }
15
+ /** Resolves true once something accepts connections on the port, false if `stop` says to give up. */
16
+ function waitListening(port, stop) {
17
+ return new Promise((resolve) => {
18
+ const tryOnce = () => {
19
+ if (stop())
20
+ return resolve(false);
21
+ const c = connect(port, "127.0.0.1");
22
+ c.once("connect", () => { c.destroy(); resolve(true); });
23
+ c.once("error", () => { c.destroy(); setTimeout(tryOnce, 100); });
24
+ };
25
+ tryOnce();
26
+ });
27
+ }
6
28
  export async function sim(argv) {
7
29
  const a = parse(argv);
8
30
  const cfg = loadConfig();
31
+ const port = a.port || "4545";
32
+ if (!(await portFree(Number(port)))) {
33
+ console.error(`Port ${port} is in use. Stop the other \`shipvo sim\` or pass --port <n>.`);
34
+ process.exit(1);
35
+ }
9
36
  if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
10
37
  console.error(`Building ${cfg.name} for the simulator…`);
11
38
  execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
@@ -18,8 +45,10 @@ export async function sim(argv) {
18
45
  console.error("Install @shipvo/sim (and playwright) in this app: npm i -D @shipvo/sim playwright && npx playwright install chromium");
19
46
  process.exit(2);
20
47
  }
21
- const port = a.port || "4545";
22
- console.error(`Simulator on http://localhost:${port} (open_app("${cfg.appId}") from @shipvo/mcp; Ctrl-C to stop)`);
23
48
  const p = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId, ...(a.dir ? ["--dir", a.dir] : [])], { stdio: "inherit", env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
24
- p.on("exit", (c) => process.exit(c ?? 0));
49
+ let exited = false;
50
+ p.on("exit", (c) => { exited = true; process.exit(c ?? 0); });
51
+ if (await waitListening(Number(port), () => exited)) {
52
+ console.error(`Simulator on http://localhost:${port} (open_app("${cfg.appId}") from @shipvo/mcp; Ctrl-C to stop)`);
53
+ }
25
54
  }
@@ -0,0 +1 @@
1
+ export declare function verifyPointer(file: string, publicKeyB64: string): boolean;
package/dist/verify.js ADDED
@@ -0,0 +1,15 @@
1
+ // `shipvo verify <pointer file> [public-key]`: check a release pointer, which is { body, signature }
2
+ // with the signature taken over the body string (see publish.ts). Read-only helper; phones use their own path.
3
+ import * as ed from "@noble/ed25519";
4
+ import { unb64 } from "./index.js";
5
+ export function verifyPointer(file, publicKeyB64) {
6
+ try {
7
+ const p = JSON.parse(file);
8
+ if (typeof p?.body !== "string" || typeof p?.signature !== "string")
9
+ return false;
10
+ return ed.verify(unb64(p.signature), new TextEncoder().encode(p.body), unb64(publicKeyB64));
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "@noble/ed25519": "^2.1.0",
13
13
  "@noble/hashes": "^1.5.0",
14
- "@shipvo/schema": "0.0.1"
14
+ "@shipvo/schema": "0.0.3"
15
15
  },
16
16
  "devDependencies": {
17
17
  "typescript": "^5.6.3",