@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 +25 -3
- package/dist/app.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/config.d.ts +5 -0
- package/dist/init.js +84 -5
- package/dist/publish.js +181 -117
- package/dist/signing.d.ts +1 -0
- package/dist/signing.js +72 -0
- package/package.json +1 -1
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
|
|
7
|
-
npx @shipvo/cli
|
|
8
|
-
npx @shipvo/cli
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
103
|
-
files.push(rel);
|
|
116
|
+
catch { }
|
|
104
117
|
}
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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"
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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>;
|
package/dist/signing.js
ADDED
|
@@ -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
|
+
}
|