@shipvo/cli 0.1.1 → 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
@@ -3,11 +3,33 @@
3
3
  Command-line tools for [Shipvo](https://github.com/shipvo/shipvo), the open-source React Native framework for signed over-the-air updates.
4
4
 
5
5
  ```bash
6
- npx @shipvo/cli keygen --out seed.txt # Ed25519 release signing key
7
- npx @shipvo/cli hash bundle.hbc # SHA-256 and size
8
- npx @shipvo/cli sign-file bundle.hbc # signature over bytes
6
+ npx -y @shipvo/cli init my-app # new app wired to Shipvo
7
+ npx -y @shipvo/cli login # sign in with GitHub, get a key for the hosted simulator
8
+ npx -y @shipvo/cli app create # register the app, save a publish token
9
+ npx -y @shipvo/cli publish # build, sign and ship an over-the-air release
10
+ npx -y @shipvo/cli sim # serve the app in the simulator for agents
9
11
  ```
10
12
 
13
+ ## Commands
14
+
15
+ | Command | What it does |
16
+ | --- | --- |
17
+ | `init <dir>` | New app with the OTA runtime, device API, simulator and publish workflow |
18
+ | `sim [--port 4545]` | Build for the simulator and serve it |
19
+ | `publish [--bands b0,b1,b2]` | Build, sign and publish a release plus the agent-facing web artifact |
20
+ | `rollback --to <release>` | Republish an earlier release's code as a new release |
21
+ | `login` | Sign in with GitHub; issues a 30-day key |
22
+ | `app create` / `list` / `delete <id> --yes` | Register the app (publish token), list yours, or remove one |
23
+ | `keys` | Status and quota of the stored sign-in key |
24
+ | `keys export [--out file]` | Print or save the release signing seed so you can back it up |
25
+ | `keys import <seed>` / `--file f` / stdin | Restore the seed on a new machine; refused if it does not match `shipvo.json` |
26
+ | `mcp` | Print the MCP config agents need |
27
+ | `keygen [--out seed.txt]` | Create an Ed25519 release signing key |
28
+ | `pubkey` | Print the public key for the current seed |
29
+ | `hash <file>`, `sign-file <file>`, `sign-json <file>`, `verify-json <file> <key>`, `verify <pointer> [key]` (release pointers) | Low-level signing helpers |
30
+
31
+ Changing computers? See [Moving to a new machine](new-machine.md).
32
+
11
33
  Also usable as a library: `signDoc`, `verifyDoc`, `signBytes`, `sha256Hex`, `keyFromSeed`.
12
34
 
13
35
  License: Apache-2.0
package/dist/app.js CHANGED
@@ -41,7 +41,7 @@ export async function appCmd(argv) {
41
41
  process.exit(2);
42
42
  }
43
43
  const cfg = loadConfig();
44
- const r = await fetch(`${c.control}/apps`, { method: "POST", headers: { authorization: `Bearer ${c.key}`, "content-type": "application/json" }, body: JSON.stringify({ app_id: cfg.appId, name: cfg.name, public_key: cfg.publicKey, description: cfg.listing?.description ?? "", category: cfg.listing?.category ?? "other", listed: !!cfg.listing?.public }) });
44
+ const r = await fetch(`${c.control}/apps`, { method: "POST", headers: { authorization: `Bearer ${c.key}`, "content-type": "application/json" }, body: JSON.stringify({ app_id: cfg.appId, name: cfg.name, public_key: cfg.publicKey, description: cfg.listing?.description ?? "", category: cfg.listing?.category ?? "other", listed: !!cfg.listing?.public, tagline: cfg.listing?.tagline ?? "", website: cfg.listing?.website ?? "" }) });
45
45
  const d = await r.json();
46
46
  if (!r.ok) {
47
47
  console.error(`Could not register "${cfg.appId}": ${d.message ?? d.hint ?? d.error}`);
package/dist/cli.js CHANGED
@@ -7,26 +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";
11
+ import { signingKey } from "./signing.js";
10
12
  import { appCmd } from "./app.js";
13
+ import { doctor } from "./doctor.js";
14
+ import { help, unknownCommand } from "./help.js";
11
15
  const [cmd, ...rest] = process.argv.slice(2);
12
- const help = `shipvo <command>
13
-
14
- init <dir> New app wired to Shipvo: OTA runtime, device API, simulator, publish workflow
15
- sim [--port 4545] Build the app for the simulator and serve it (agents: open_app("<appId>"))
16
- publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release (+ the agent-facing web artifact)
17
- rollback --to <release> Republish an earlier release's code as a new release
18
- app create | list | delete Register this app with the hosted control plane (publish token), list yours, or remove one
19
- login Sign in with GitHub and get a key for the hosted simulator
20
- keys Show the stored key's status and quota
21
- mcp Print the MCP config agents need (hosted and local)
22
-
23
- keygen [--out seed.txt] Create an Ed25519 release signing key; prints the public key
24
- pubkey Print the public key for the seed in SHIPVO_SIGNING_SEED
25
- hash <file> SHA-256 and size of a bundle
26
- sign-file <file> Ed25519 signature (base64) over a file's bytes
27
- sign-json <file.json> Sign a JSON document (canonical form); writes signature into it
28
- verify-json <file.json> <public-key-b64>
29
- `;
30
16
  switch (cmd) {
31
17
  case "init":
32
18
  await init(rest);
@@ -43,11 +29,17 @@ switch (cmd) {
43
29
  case "login":
44
30
  await login(rest);
45
31
  break;
32
+ case "doctor":
33
+ await doctor(rest);
34
+ break;
46
35
  case "app":
47
36
  await appCmd(rest);
48
37
  break;
49
38
  case "keys":
50
- await keys();
39
+ if (rest[0] === "export" || rest[0] === "import")
40
+ await signingKey(rest);
41
+ else
42
+ await keys();
51
43
  break;
52
44
  case "mcp":
53
45
  mcp();
@@ -86,7 +78,31 @@ switch (cmd) {
86
78
  console.log(ok ? "valid" : "INVALID");
87
79
  process.exit(ok ? 0 : 1);
88
80
  }
89
- 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":
90
103
  console.log(help);
91
- process.exit(cmd ? 1 : 0);
104
+ break;
105
+ default:
106
+ console.error(unknownCommand(cmd));
107
+ process.exit(1);
92
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;
@@ -16,6 +18,11 @@ export type ShipvoConfig = {
16
18
  public?: boolean;
17
19
  description?: string;
18
20
  category?: string;
21
+ tagline?: string;
22
+ icon?: string;
23
+ screenshots?: string[];
24
+ website?: string;
25
+ capabilities?: string[];
19
26
  };
20
27
  };
21
28
  export declare function loadConfig(dir?: string): ShipvoConfig;
@@ -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
@@ -34,17 +34,17 @@ export async function init(argv) {
34
34
  seedNote = `A release signing key was created at .shipvo/seed.txt (git-ignored). Back it up offline and put it in the SHIPVO_SIGNING_SEED secret of your CI. Lose it and you cannot publish to installed shells.`;
35
35
  }
36
36
  const w = (p, body) => { mkdirSync(join(dir, p, ".."), { recursive: true }); writeFileSync(join(dir, p), body); };
37
- w("shipvo.json", JSON.stringify({ appId, name, shellMajor: 1, controlPlane, publicKey, listing: { public: false, description: "", category: "other" } }, null, 2) + "\n");
37
+ w("shipvo.json", JSON.stringify({ appId, name, shellMajor: 1, controlPlane, publicKey, listing: { public: false, tagline: "", description: "", category: "other", icon: "assets/icon.png", screenshots: [], website: "", capabilities: ["camera", "location", "biometrics", "payments", "notifications"] } }, null, 2) + "\n");
38
38
  w("package.json", JSON.stringify({
39
39
  name: appId, version: "1.0.0", main: "index.ts", private: true,
40
- scripts: { start: "expo start", android: "expo run:android", ios: "expo run:ios", web: "expo start --web", sim: "shipvo sim", publish: "shipvo publish", rollback: "shipvo rollback", typecheck: "tsc --noEmit" },
40
+ scripts: { start: "expo start", test: "vitest run", android: "expo run:android", ios: "expo run:ios", web: "expo start --web", sim: "shipvo sim", publish: "shipvo publish", rollback: "shipvo rollback", typecheck: "tsc --noEmit" },
41
41
  dependencies: {
42
42
  "@expo/metro-runtime": SDK.metroRuntime, expo: SDK.expo, "expo-camera": SDK.camera, "expo-clipboard": SDK.clipboard, "expo-constants": SDK.constants,
43
43
  "expo-local-authentication": SDK.localAuth, "expo-location": SDK.location, "expo-notifications": SDK.notifications, "expo-status-bar": SDK.statusBar,
44
44
  react: SDK.react, "react-dom": SDK.react, "react-native": SDK.rn, "react-native-web": SDK.rnw,
45
45
  "@shipvo/device": "^0.1.0", "@shipvo/runtime": "^0.1.0",
46
46
  },
47
- devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@types/react": "~19.3.0", playwright: "^1.63.0", typescript: "~6.0.3" },
47
+ devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: "^1.63.0", typescript: "~6.0.3", vitest: "^2.1.4" },
48
48
  }, null, 2) + "\n");
49
49
  w("app.json", JSON.stringify({ expo: {
50
50
  name, slug: appId, version: "1.0.0", orientation: "portrait", userInterfaceStyle: "automatic",
@@ -68,10 +68,13 @@ 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 push to main." };
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);
75
+ w(".github/workflows/test.yml", TEST_WORKFLOW);
76
+ w("tests/app.test.ts", TEST_APP(name));
77
+ w("vitest.config.ts", VITEST_CONFIG);
75
78
  w("README.md", README(name, appId, controlPlane));
76
79
  console.log(`
77
80
  Created ${name} in ${dirArg}
@@ -82,7 +85,7 @@ Created ${name} in ${dirArg}
82
85
  npm run sim # the app in the simulator on http://localhost:4545
83
86
  npx -y @shipvo/mcp # point Claude / Cursor / ChatGPT at it and open_app("${appId}")
84
87
 
85
- Phones: npx expo run:android (needs Android Studio once; the OTA runtime is Android-first)
88
+ Phones: npx expo run:android (needs Android Studio once). iOS: npx expo run:ios, then shipvo publish --platform all
86
89
  Publish: shipvo login, then shipvo app create (hosted), or set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and SHIPVO_KV_ID (self-hosted); then npm run publish
87
90
  (self-hosting instead? see docs/self-hosting.md)
88
91
 
@@ -108,7 +111,7 @@ export default function App() {
108
111
 
109
112
  // Over-the-air updates: tell the runtime the first screen rendered (so this release is kept), then poll while open.
110
113
  useEffect(() => {
111
- 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 {}
112
115
  const check = async () => {
113
116
  if (busy.current || !state?.baseUrl) return; busy.current = true;
114
117
  try { const r = await Runtime.checkForUpdate(true); if (r.status === "staged") add(\`Applied release \${r.release} (\${r.via ?? "full"})\`); }
@@ -137,7 +140,7 @@ export default function App() {
137
140
  <Btn label="Send a notification" onPress={notify} />
138
141
  <Text style={s.h2} accessibilityRole="header">Log</Text>
139
142
  {log.length === 0 ? <Text style={s.muted}>Nothing yet.</Text> : log.map((l, i) => <Text key={i} style={s.line}>{l}</Text>)}
140
- <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>
141
144
  </ScrollView>
142
145
  <ScanHost accent="#1F4E5F" />
143
146
  </View>
@@ -159,6 +162,79 @@ const s = StyleSheet.create({
159
162
  line: { fontSize: 14, color: "#111" }, muted: { fontSize: 13, color: "#5B6B66" },
160
163
  });
161
164
  `;
165
+ const TEST_WORKFLOW = `name: test
166
+ # Runs your app in the Shipvo simulator on a plain Linux runner (no emulator, no device) and fails the build if it breaks.
167
+ on:
168
+ pull_request:
169
+ push:
170
+ branches: [main]
171
+ workflow_call:
172
+ jobs:
173
+ simulator:
174
+ runs-on: ubuntu-latest
175
+ timeout-minutes: 20
176
+ steps:
177
+ - uses: actions/checkout@v4
178
+ - uses: actions/setup-node@v4
179
+ with: { node-version: 22 }
180
+ - run: npm ci
181
+ - run: npx playwright install --with-deps chromium
182
+ - name: Build the app for the simulator and serve it
183
+ run: |
184
+ npx shipvo sim --port 4545 > sim.log 2>&1 &
185
+ for i in $(seq 1 180); do curl -sf localhost:4545/app/ >/dev/null && break; sleep 1; done
186
+ curl -sf localhost:4545/app/ >/dev/null || { cat sim.log; exit 1; }
187
+ - name: Test the app in the simulator
188
+ run: npx vitest run
189
+ - uses: actions/upload-artifact@v4
190
+ if: failure()
191
+ with: { name: simulator-log, path: sim.log }
192
+ `;
193
+ const TEST_APP = (name) => `// Tests that run your app in the Shipvo simulator: a phone-sized, headless copy of the real app. No emulator, no device.
194
+ // Run them with "npm test" (start the simulator first: npm run sim) and in CI (.github/workflows/test.yml).
195
+ import { test, expect } from "@shipvo/test/vitest";
196
+
197
+ test("the app opens and shows its headline", async ({ phone }) => {
198
+ expect(phone).toSee("Hello from ${name}.");
199
+ expect(phone.screen).not.toMatch(/something went wrong|unhandled|exception/i);
200
+ });
201
+
202
+ test("location: the app asks, then reads the phone's location", async ({ phone }) => {
203
+ await phone.device.set({ location: { latitude: 4.0511, longitude: 9.7679 } });
204
+ await phone.tap("Use my location");
205
+ expect(phone).toSee("access this device's location");
206
+ await phone.tap("While using the app");
207
+ await phone.waitFor(/4\\.05/);
208
+ });
209
+
210
+ test("camera: scanning reads what is in front of it", async ({ phone }) => {
211
+ await phone.device.set({ camera: { barcode: { type: "qr", data: "HELLO-123" } } });
212
+ await phone.tap("Scan a code");
213
+ await phone.tap("While using the app");
214
+ await phone.waitFor(/HELLO-123/);
215
+ });
216
+
217
+ test("fingerprint: success and failure are both handled", async ({ phone }) => {
218
+ await phone.device.set({ biometrics: { enrolled: true, nextResult: "success" } });
219
+ await phone.tap("Confirm with fingerprint");
220
+ await phone.tap("Use fingerprint");
221
+ await phone.waitFor("Confirmed");
222
+ await phone.device.set({ biometrics: { nextResult: "fail" } });
223
+ await phone.tap("Confirm with fingerprint");
224
+ await phone.tap("Use fingerprint");
225
+ await phone.waitFor(/Not confirmed/);
226
+ });
227
+
228
+ test("notifications: a push can be opened from the shade", async ({ phone }) => {
229
+ await phone.tap("Send a notification");
230
+ expect(await phone.notifications()).toHaveLength(1);
231
+ await phone.openNotification();
232
+ expect(await phone.deviceLog()).toContainEqual(expect.objectContaining({ kind: "notification_opened" }));
233
+ });
234
+ `;
235
+ const VITEST_CONFIG = `import { defineConfig } from "vitest/config";
236
+ export default defineConfig({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30_000 } });
237
+ `;
162
238
  const WORKFLOW = `name: publish
163
239
  on:
164
240
  push:
@@ -166,7 +242,10 @@ on:
166
242
  paths: ["src/**", "App.tsx"]
167
243
  workflow_dispatch:
168
244
  jobs:
245
+ test:
246
+ uses: ./.github/workflows/test.yml # the simulator test must pass before anything ships to phones
169
247
  publish:
248
+ needs: test
170
249
  runs-on: ubuntu-latest
171
250
  concurrency: shipvo-publish
172
251
  if: \${{ github.event_name != 'push' || !contains(github.event.head_commit.message, '[no-ota]') }}
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,153 +1,319 @@
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 { 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
- const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "application/json", ".css": "text/css", ".ico": "image/x-icon", ".png": "image/png", ".ttf": "font/ttf", ".svg": "image/svg+xml" };
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)]));
12
+ /** Release numbers are shared across platforms, so one number means one commit everywhere. */
11
13
  async function currentRelease(kv, app, major) {
12
14
  let max = 0;
13
- for (const b of ["b0", "b1", "b2"]) {
14
- const raw = await kv.get(`p/${app}/${major}/${b}`);
15
- if (raw)
16
- try {
17
- max = Math.max(max, JSON.parse(JSON.parse(raw).body).release);
18
- }
19
- catch { }
20
- }
15
+ for (const b of ["b0", "b1", "b2"])
16
+ for (const k of [`p/${app}/${major}/${b}`, `p/${app}/${major}/ios/${b}`]) {
17
+ const raw = await kv.get(k);
18
+ if (raw)
19
+ try {
20
+ max = Math.max(max, JSON.parse(JSON.parse(raw).body).release);
21
+ }
22
+ catch { }
23
+ }
21
24
  return max;
22
25
  }
26
+ const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
27
+ /** Android keeps its original key names; iOS bundles, patches and history are namespaced so the two never collide. */
28
+ const bundleName = (release, pf) => (pf === "ios" ? `ios-r${release}.hbc` : `r${release}.hbc`);
29
+ const platformsOf = (a) => {
30
+ const p = (a.platform || "android").toLowerCase();
31
+ if (p === "all")
32
+ return ["android", "ios"];
33
+ if (p === "android" || p === "ios")
34
+ return [p];
35
+ throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
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
+ `;
23
84
  export async function publish(argv) {
24
- 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);
25
98
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
26
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);
27
103
  const bands = (a.bands || "b0,b1,b2").split(",");
104
+ const platforms = platformsOf(a);
28
105
  const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
29
- if (a["skip-export"] !== "true")
30
- execSync("npx expo export --platform android --output-dir dist-ota --clear", { stdio: "inherit" });
31
- const jsDir = "dist-ota/_expo/static/js/android";
32
- const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
33
- if (!file)
34
- throw new Error("no bundle in " + jsDir);
35
- const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
36
- const name = `r${release}.hbc`, newSha = sha256Hex(bytes);
37
- await kv.put(`b/${APP}/${name}`, bytes, "application/octet-stream");
38
- // Patches from recent releases and from each shell's embedded bundle; devices verify the patched result against the signed sha256.
39
- const MAX_BASES = Number(a["max-bases"] || 6);
40
- const bases = new Map();
41
- const hist = (await Promise.all((await kv.keys(`h/${APP}/r`)).map(async (k) => { try {
42
- return JSON.parse((await kv.get(k)));
43
- }
44
- catch {
45
- return null;
46
- } })))
47
- .filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
48
- for (const h of hist)
49
- bases.set(h.sha256, `b/${APP}/r${h.release}.hbc`);
50
- for (const k of await kv.keys(`base/${APP}/`)) {
51
- try {
52
- const b = JSON.parse((await kv.get(k)));
53
- bases.set(b.sha256, b.key);
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 };
111
+ for (const pf of platforms) {
112
+ // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
113
+ const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
114
+ if (a["skip-export"] !== "true")
115
+ execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: "inherit" });
116
+ const jsDir = `${root}/_expo/static/js/${pf}`;
117
+ const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
118
+ if (!file)
119
+ throw new Error("no bundle in " + jsDir);
120
+ const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
121
+ const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
122
+ const name = bundleName(release, pf), newSha = sha256Hex(bytes);
123
+ await kv.put(`b/${APP}/${name}`, bytes, "application/octet-stream");
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.
126
+ const patches = [];
127
+ {
128
+ const MAX_BASES = Number(a["max-bases"] || 6);
129
+ const bases = new Map();
130
+ const hist = (await Promise.all((await kv.keys(pf === "ios" ? `h/${APP}/ios-r` : `h/${APP}/r`)).map(async (k) => { try {
131
+ return JSON.parse((await kv.get(k)));
132
+ }
133
+ catch {
134
+ return null;
135
+ } })))
136
+ .filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
137
+ for (const h of hist)
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) {
150
+ try {
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.");
156
+ }
157
+ }
158
+ if (bsdiff) {
159
+ const work = mkdtempSync(join(tmpdir(), "shipvo-"));
160
+ writeFileSync(join(work, "new.hbc"), bytes);
161
+ for (const [fromSha, key] of bases) {
162
+ if (fromSha === newSha)
163
+ continue;
164
+ const base = await kv.getBytes(key);
165
+ if (!base)
166
+ continue;
167
+ writeFileSync(join(work, "old.hbc"), base);
168
+ execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
169
+ const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
170
+ if (patch.length >= bytes.length * 0.6)
171
+ continue;
172
+ const pkey = `b/${APP}/${pf === "ios" ? "ios-" : ""}d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
173
+ await kv.put(pkey, patch, "application/octet-stream");
174
+ patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
175
+ }
176
+ }
177
+ patchInfo.bases = bases.size;
178
+ patchInfo.bsdiff = bsdiff;
179
+ patchInfo.patches = patches.length;
180
+ console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
54
181
  }
55
- catch { }
56
- }
57
- const patches = [];
58
- let bsdiff = true;
59
- try {
60
- execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
61
- }
62
- catch {
63
- bsdiff = false;
64
- console.error("bsdiff4 not installed (pip install bsdiff4): publishing full bundles only");
65
- }
66
- if (bsdiff) {
67
- const work = mkdtempSync(join(tmpdir(), "shipvo-"));
68
- writeFileSync(join(work, "new.hbc"), bytes);
69
- for (const [fromSha, key] of bases) {
70
- if (fromSha === newSha)
71
- continue;
72
- const base = await kv.getBytes(key);
73
- if (!base)
74
- continue;
75
- writeFileSync(join(work, "old.hbc"), base);
76
- execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
77
- const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
78
- if (patch.length >= bytes.length * 0.6)
79
- continue;
80
- const pkey = `b/${APP}/d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
81
- await kv.put(pkey, patch, "application/octet-stream");
82
- patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
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).
183
+ let web = null;
184
+ if (a["skip-web"] !== "true" && pf === platforms[0]) {
185
+ web = await publishWeb(kv, cfg, APP, release);
83
186
  }
84
- }
85
- console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
86
- // The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator.
87
- let web = null;
88
- if (a["skip-web"] !== "true") {
89
- execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
90
- const base = `w/${APP}/r${release}`;
91
- const files = [];
92
- let indexSha = "";
93
- for (const f of walk("dist-web")) {
94
- const rel = f.slice("dist-web/".length);
95
- if (rel === "metadata.json")
96
- continue;
97
- let body = readFileSync(f);
98
- if (rel === "index.html") {
99
- body = Buffer.from(body.toString("utf8").replace(/(src|href)="\//g, `$1="/${base}/`));
100
- indexSha = sha256Hex(new Uint8Array(body));
187
+ else if (a["skip-web"] !== "true") {
188
+ try {
189
+ web = JSON.parse((await kv.get(`h/${APP}/r${release}`)))?.web ?? null;
101
190
  }
102
- await kv.put(`${base}/${rel}`, new Uint8Array(body), MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream");
103
- files.push(rel);
191
+ catch { }
104
192
  }
105
- web = { base: `/${base}/`, entry: "index.html", sha256: indexSha, files: files.length };
106
- const idxKey = `w/${APP}/releases`;
107
- let idx = { releases: [] };
108
- try {
109
- idx = JSON.parse((await kv.get(idxKey))) ?? idx;
193
+ const issued_at = new Date().toISOString();
194
+ for (const band of bands) {
195
+ const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
196
+ core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
197
+ addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(web ? { web } : {}) });
198
+ await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
110
199
  }
111
- catch { }
112
- idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
113
- idx.latest = Math.max(...idx.releases);
114
- await kv.put(idxKey, JSON.stringify(idx), "application/json");
115
- console.error(`web artifact: ${files.length} files at /${base}/`);
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) })) };
203
+ if (web)
204
+ summary.web = web;
205
+ }
206
+ if (platforms.length === 1 && platforms[0] === "android") { // original single-platform output shape
207
+ const an = summary.android;
208
+ Object.assign(summary, { bytes: an.bytes, patches: an.patches });
209
+ delete summary.android;
210
+ }
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");
237
+ }
238
+ async function publishWeb(kv, cfg, APP, release) {
239
+ execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
240
+ const base = `w/${APP}/r${release}`;
241
+ const files = [];
242
+ let indexSha = "";
243
+ for (const f of walk("dist-web")) {
244
+ const rel = f.slice("dist-web/".length);
245
+ if (rel === "metadata.json")
246
+ continue;
247
+ let body = readFileSync(f);
248
+ if (rel === "index.html") {
249
+ body = Buffer.from(body.toString("utf8").replace(/(src|href)="\//g, `$1="/${base}/`));
250
+ indexSha = sha256Hex(new Uint8Array(body));
251
+ }
252
+ await kv.put(`${base}/${rel}`, new Uint8Array(body), MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream");
253
+ files.push(rel);
254
+ }
255
+ const web = { base: `/${base}/`, entry: "index.html", sha256: indexSha, files: files.length };
256
+ const idxKey = `w/${APP}/releases`;
257
+ let idx = { releases: [] };
258
+ try {
259
+ idx = JSON.parse((await kv.get(idxKey))) ?? idx;
260
+ }
261
+ catch { }
262
+ idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
263
+ idx.latest = Math.max(...idx.releases);
264
+ await kv.put(idxKey, JSON.stringify(idx), "application/json");
265
+ console.error(`web artifact: ${files.length} files at /${base}/`);
266
+ // Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
267
+ const L = cfg.listing ?? {};
268
+ const store = `w/${APP}/store`;
269
+ const up = async (file, name) => { if (!existsSync(file)) {
270
+ console.error(`listing: ${file} not found, skipped`);
271
+ return null;
272
+ } 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}`; };
273
+ const icon = L.icon ? await up(L.icon, "icon") : null;
274
+ const shots = [];
275
+ for (const [i, f] of (L.screenshots ?? []).entries()) {
276
+ const n = await up(f, `shot-${i + 1}`);
277
+ if (n)
278
+ shots.push(n);
116
279
  }
117
- const issued_at = new Date().toISOString();
118
- for (const band of bands) {
119
- const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, release, issued_at,
120
- core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
121
- addons: [], screens: [], min_shell_version: MIN_SHELL, ...(web ? { web } : {}) });
122
- await kv.put(`p/${APP}/${MAJOR}/${band}`, JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
280
+ let note = {};
281
+ try {
282
+ const src = readFileSync("src/release.ts", "utf8");
283
+ note = { headline: src.match(/headline:\s*"([^"]*)"/)?.[1], note: src.match(/note:\s*"([^"]*)"/)?.[1] };
123
284
  }
124
- await kv.put(`h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, web }), "application/json");
125
- console.log(JSON.stringify({ app: APP, release, bands, bytes: bytes.length, min_shell_version: MIN_SHELL, web, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) }));
285
+ catch { }
286
+ await kv.put(`${store}/manifest.json`, JSON.stringify({ icon, screenshots: shots, tagline: L.tagline ?? "", website: L.website ?? "", capabilities: L.capabilities ?? [], release, release_note: note, updated_at: new Date().toISOString() }), "application/json");
287
+ return web;
126
288
  }
127
289
  export async function rollback(argv) {
290
+ checkFlags(argv, ["to", "bands", "platform"]);
128
291
  const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
129
292
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
130
293
  if (!to) {
131
- console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2]");
294
+ console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all]");
132
295
  process.exit(2);
133
296
  }
134
- const bytes = await kv.getBytes(`b/${APP}/r${to}.hbc`);
135
- if (!bytes) {
136
- console.error(`Release ${to} bundle not found`);
137
- process.exit(1);
138
- }
139
297
  const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
140
- let minShell = "1.0.0", web = undefined;
141
- try {
142
- const h = JSON.parse((await kv.get(`h/${APP}/r${to}`)));
143
- minShell = h.min_shell_version || minShell;
144
- web = h.web ?? undefined;
145
- }
146
- catch { }
147
- for (const band of (a.bands || "b0,b1,b2").split(",")) {
148
- const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, release, issued_at,
149
- core: { version: `1.0.${release}`, sha256: sha256Hex(bytes), size: bytes.length, url: `b/${APP}/r${to}.hbc` }, addons: [], screens: [], min_shell_version: minShell, ...(web ? { web } : {}) });
150
- await kv.put(`p/${APP}/${MAJOR}/${band}`, JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
298
+ let minShell = "1.0.0";
299
+ for (const pf of platformsOf(a)) {
300
+ const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
301
+ if (!bytes) {
302
+ console.error(`Release ${to} ${pf} bundle not found`);
303
+ process.exit(1);
304
+ }
305
+ let web = undefined;
306
+ try {
307
+ const h = JSON.parse((await kv.get(pf === "ios" ? `h/${APP}/ios-r${to}` : `h/${APP}/r${to}`)));
308
+ minShell = h.min_shell_version || minShell;
309
+ web = h.web ?? undefined;
310
+ }
311
+ catch { }
312
+ for (const band of (a.bands || "b0,b1,b2").split(",")) {
313
+ const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
314
+ core: { version: `1.0.${release}`, sha256: sha256Hex(bytes), size: bytes.length, url: `b/${APP}/${bundleName(to, pf)}` }, addons: [], screens: [], min_shell_version: minShell, ...(web ? { web } : {}) });
315
+ await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
316
+ }
151
317
  }
152
318
  console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
153
319
  }
@@ -0,0 +1 @@
1
+ export declare function signingKey(argv: string[]): Promise<void>;
@@ -0,0 +1,72 @@
1
+ // `shipvo keys export` / `shipvo keys import`: move the release signing seed between machines.
2
+ // The seed is the only thing that can update phones that already have the app installed
3
+ // (every installed shell pins its public key), so moving it is an explicit step, not a hidden file copy.
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { b64, keyFromSeed } from "./index.js";
7
+ import { args, loadConfig, seed } from "./config.js";
8
+ const pub = (s) => b64(keyFromSeed(s).publicKey);
9
+ const valid = (s) => { try {
10
+ return /^[A-Za-z0-9+/=_-]{43,44}$/.test(s) && !!pub(s);
11
+ }
12
+ catch {
13
+ return false;
14
+ } };
15
+ export async function signingKey(argv) {
16
+ const [sub, ...rest] = argv, a = args(rest);
17
+ const cfg = loadConfig();
18
+ if (sub === "export") {
19
+ const s = seed();
20
+ if (pub(s) !== cfg.publicKey) {
21
+ console.error(`This seed does not match the public key in shipvo.json (${cfg.publicKey.slice(0, 12)}…). It would not be accepted by installed shells.`);
22
+ process.exit(1);
23
+ }
24
+ if (a.out && a.out !== "true") {
25
+ writeFileSync(a.out, s + "\n", { mode: 0o600 });
26
+ console.error(`Seed for "${cfg.appId}" written to ${a.out}. Store it in a password manager or an offline vault, then delete this file.`);
27
+ }
28
+ else {
29
+ console.error(`SECRET: anyone with this seed can ship code to every phone that has "${cfg.appId}". Keep it in a password manager; never paste it into chat, commits or tickets.\n`);
30
+ console.log(s);
31
+ }
32
+ console.error(`Public key (not secret, pinned in installed shells): ${cfg.publicKey}`);
33
+ return;
34
+ }
35
+ // import: seed from an argument, --file, or stdin
36
+ let s = rest.find((x, i) => !x.startsWith("--") && rest[i - 1] !== "--file") ?? "";
37
+ if (!s && a.file && a.file !== "true")
38
+ s = readFileSync(a.file, "utf8");
39
+ if (!s && !process.stdin.isTTY)
40
+ s = readFileSync(0, "utf8");
41
+ s = s.trim();
42
+ if (!s) {
43
+ console.error("Usage: shipvo keys import <seed> | shipvo keys import --file seed.txt | echo <seed> | shipvo keys import");
44
+ process.exit(2);
45
+ }
46
+ if (!valid(s)) {
47
+ console.error("That does not look like a Shipvo signing seed (a 32-byte value in base64).");
48
+ process.exit(1);
49
+ }
50
+ if (pub(s) !== cfg.publicKey) {
51
+ console.error(`This seed belongs to a different key (${pub(s).slice(0, 12)}…), but shipvo.json pins ${cfg.publicKey.slice(0, 12)}….\nPublishing with it would be rejected by the control plane and ignored by installed shells.`);
52
+ process.exit(1);
53
+ }
54
+ const dir = join(process.cwd(), ".shipvo"), file = join(dir, "seed.txt");
55
+ if (existsSync(file) && readFileSync(file, "utf8").trim() === s) {
56
+ console.log("Already in place. Nothing to do.");
57
+ return;
58
+ }
59
+ mkdirSync(dir, { recursive: true });
60
+ writeFileSync(file, s + "\n", { mode: 0o600 });
61
+ try {
62
+ chmodSync(file, 0o600);
63
+ }
64
+ catch { }
65
+ console.log(`Signing key for "${cfg.appId}" restored to .shipvo/seed.txt (matches shipvo.json).`);
66
+ try {
67
+ if (!/^\.shipvo\/?$/m.test(readFileSync(join(process.cwd(), ".gitignore"), "utf8")))
68
+ console.log("Warning: .gitignore does not ignore .shipvo/. Add it so the seed is never committed.");
69
+ }
70
+ catch { }
71
+ console.log("Next: `shipvo login` then `shipvo app create` to get a publish token on this machine.");
72
+ }
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.1",
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",