@shipvo/cli 0.1.1 → 0.1.2

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>` | 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,6 +7,7 @@ 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 { signingKey } from "./signing.js";
10
11
  import { appCmd } from "./app.js";
11
12
  const [cmd, ...rest] = process.argv.slice(2);
12
13
  const help = `shipvo <command>
@@ -18,6 +19,8 @@ const help = `shipvo <command>
18
19
  app create | list | delete Register this app with the hosted control plane (publish token), list yours, or remove one
19
20
  login Sign in with GitHub and get a key for the hosted simulator
20
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)
21
24
  mcp Print the MCP config agents need (hosted and local)
22
25
 
23
26
  keygen [--out seed.txt] Create an Ed25519 release signing key; prints the public key
@@ -47,7 +50,10 @@ switch (cmd) {
47
50
  await appCmd(rest);
48
51
  break;
49
52
  case "keys":
50
- await keys();
53
+ if (rest[0] === "export" || rest[0] === "import")
54
+ await signingKey(rest);
55
+ else
56
+ await keys();
51
57
  break;
52
58
  case "mcp":
53
59
  mcp();
package/dist/config.d.ts CHANGED
@@ -16,6 +16,11 @@ export type ShipvoConfig = {
16
16
  public?: boolean;
17
17
  description?: string;
18
18
  category?: string;
19
+ tagline?: string;
20
+ icon?: string;
21
+ screenshots?: string[];
22
+ website?: string;
23
+ capabilities?: string[];
19
24
  };
20
25
  };
21
26
  export declare function loadConfig(dir?: string): ShipvoConfig;
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: "Release 1: 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
 
@@ -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 release", async ({ phone }) => {
198
+ expect(phone).toSee(/Release \\d+/);
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/publish.js CHANGED
@@ -1,153 +1,217 @@
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, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { sha256Hex, signBytes } from "./index.js";
8
8
  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" };
9
+ 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
10
  const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
11
+ /** Release numbers are shared across platforms, so one number means one commit everywhere. */
11
12
  async function currentRelease(kv, app, major) {
12
13
  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
- }
14
+ for (const b of ["b0", "b1", "b2"])
15
+ for (const k of [`p/${app}/${major}/${b}`, `p/${app}/${major}/ios/${b}`]) {
16
+ const raw = await kv.get(k);
17
+ if (raw)
18
+ try {
19
+ max = Math.max(max, JSON.parse(JSON.parse(raw).body).release);
20
+ }
21
+ catch { }
22
+ }
21
23
  return max;
22
24
  }
25
+ const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
26
+ /** Android keeps its original key names; iOS bundles, patches and history are namespaced so the two never collide. */
27
+ const bundleName = (release, pf) => (pf === "ios" ? `ios-r${release}.hbc` : `r${release}.hbc`);
28
+ const platformsOf = (a) => {
29
+ const p = (a.platform || "android").toLowerCase();
30
+ if (p === "all")
31
+ return ["android", "ios"];
32
+ if (p === "android" || p === "ios")
33
+ return [p];
34
+ throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
35
+ };
23
36
  export async function publish(argv) {
24
37
  const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
25
38
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
26
39
  const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
27
40
  const bands = (a.bands || "b0,b1,b2").split(",");
41
+ const platforms = platformsOf(a);
28
42
  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);
43
+ const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL };
44
+ for (const pf of platforms) {
45
+ // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
46
+ const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
47
+ if (a["skip-export"] !== "true")
48
+ execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: "inherit" });
49
+ const jsDir = `${root}/_expo/static/js/${pf}`;
50
+ const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
51
+ if (!file)
52
+ throw new Error("no bundle in " + jsDir);
53
+ const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
54
+ const name = bundleName(release, pf), newSha = sha256Hex(bytes);
55
+ 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.
58
+ const patches = [];
59
+ if (pf === "android") {
60
+ const MAX_BASES = Number(a["max-bases"] || 6);
61
+ const bases = new Map();
62
+ const hist = (await Promise.all((await kv.keys(`h/${APP}/r`)).map(async (k) => { try {
63
+ return JSON.parse((await kv.get(k)));
64
+ }
65
+ catch {
66
+ return null;
67
+ } })))
68
+ .filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
69
+ 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}/`)) {
72
+ try {
73
+ const b = JSON.parse((await kv.get(k)));
74
+ bases.set(b.sha256, b.key);
75
+ }
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
+ }
86
+ if (bsdiff) {
87
+ const work = mkdtempSync(join(tmpdir(), "shipvo-"));
88
+ writeFileSync(join(work, "new.hbc"), bytes);
89
+ for (const [fromSha, key] of bases) {
90
+ if (fromSha === newSha)
91
+ continue;
92
+ const base = await kv.getBytes(key);
93
+ if (!base)
94
+ continue;
95
+ writeFileSync(join(work, "old.hbc"), base);
96
+ execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
97
+ const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
98
+ if (patch.length >= bytes.length * 0.6)
99
+ continue;
100
+ const pkey = `b/${APP}/d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
101
+ await kv.put(pkey, patch, "application/octet-stream");
102
+ patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
103
+ }
104
+ }
105
+ console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
54
106
  }
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) });
107
+ // The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator (built once, with the first platform).
108
+ let web = null;
109
+ if (a["skip-web"] !== "true" && pf === platforms[0]) {
110
+ web = await publishWeb(kv, cfg, APP, release);
83
111
  }
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));
112
+ else if (a["skip-web"] !== "true") {
113
+ try {
114
+ web = JSON.parse((await kv.get(`h/${APP}/r${release}`)))?.web ?? null;
101
115
  }
102
- await kv.put(`${base}/${rel}`, new Uint8Array(body), MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream");
103
- files.push(rel);
116
+ catch { }
104
117
  }
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;
118
+ const issued_at = new Date().toISOString();
119
+ for (const band of bands) {
120
+ const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
121
+ 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 } : {}) });
123
+ await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
110
124
  }
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}/`);
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) })) } : {}) };
127
+ if (web)
128
+ summary.web = web;
129
+ }
130
+ if (platforms.length === 1 && platforms[0] === "android") { // original single-platform output shape
131
+ const an = summary.android;
132
+ Object.assign(summary, { bytes: an.bytes, patches: an.patches });
133
+ delete summary.android;
134
+ }
135
+ console.log(JSON.stringify(summary));
136
+ }
137
+ async function publishWeb(kv, cfg, APP, release) {
138
+ execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
139
+ const base = `w/${APP}/r${release}`;
140
+ const files = [];
141
+ let indexSha = "";
142
+ for (const f of walk("dist-web")) {
143
+ const rel = f.slice("dist-web/".length);
144
+ if (rel === "metadata.json")
145
+ continue;
146
+ let body = readFileSync(f);
147
+ if (rel === "index.html") {
148
+ body = Buffer.from(body.toString("utf8").replace(/(src|href)="\//g, `$1="/${base}/`));
149
+ indexSha = sha256Hex(new Uint8Array(body));
150
+ }
151
+ await kv.put(`${base}/${rel}`, new Uint8Array(body), MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream");
152
+ files.push(rel);
153
+ }
154
+ const web = { base: `/${base}/`, entry: "index.html", sha256: indexSha, files: files.length };
155
+ const idxKey = `w/${APP}/releases`;
156
+ let idx = { releases: [] };
157
+ try {
158
+ idx = JSON.parse((await kv.get(idxKey))) ?? idx;
159
+ }
160
+ catch { }
161
+ idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
162
+ idx.latest = Math.max(...idx.releases);
163
+ await kv.put(idxKey, JSON.stringify(idx), "application/json");
164
+ console.error(`web artifact: ${files.length} files at /${base}/`);
165
+ // Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
166
+ const L = cfg.listing ?? {};
167
+ const store = `w/${APP}/store`;
168
+ const up = async (file, name) => { if (!existsSync(file)) {
169
+ console.error(`listing: ${file} not found, skipped`);
170
+ return null;
171
+ } 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}`; };
172
+ const icon = L.icon ? await up(L.icon, "icon") : null;
173
+ const shots = [];
174
+ for (const [i, f] of (L.screenshots ?? []).entries()) {
175
+ const n = await up(f, `shot-${i + 1}`);
176
+ if (n)
177
+ shots.push(n);
116
178
  }
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");
179
+ let note = {};
180
+ try {
181
+ const src = readFileSync("src/release.ts", "utf8");
182
+ note = { headline: src.match(/headline:\s*"([^"]*)"/)?.[1], note: src.match(/note:\s*"([^"]*)"/)?.[1] };
123
183
  }
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) })) }));
184
+ catch { }
185
+ 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");
186
+ return web;
126
187
  }
127
188
  export async function rollback(argv) {
128
189
  const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
129
190
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
130
191
  if (!to) {
131
- console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2]");
192
+ console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all]");
132
193
  process.exit(2);
133
194
  }
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
195
  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");
196
+ let minShell = "1.0.0";
197
+ for (const pf of platformsOf(a)) {
198
+ const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
199
+ if (!bytes) {
200
+ console.error(`Release ${to} ${pf} bundle not found`);
201
+ process.exit(1);
202
+ }
203
+ let web = undefined;
204
+ try {
205
+ const h = JSON.parse((await kv.get(pf === "ios" ? `h/${APP}/ios-r${to}` : `h/${APP}/r${to}`)));
206
+ minShell = h.min_shell_version || minShell;
207
+ web = h.web ?? undefined;
208
+ }
209
+ catch { }
210
+ for (const band of (a.bands || "b0,b1,b2").split(",")) {
211
+ const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
212
+ 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 } : {}) });
213
+ await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
214
+ }
151
215
  }
152
216
  console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
153
217
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {