@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 +25 -3
- package/dist/app.js +1 -1
- package/dist/cli.js +37 -21
- package/dist/config.d.ts +7 -0
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.js +109 -0
- package/dist/help.d.ts +6 -0
- package/dist/help.js +61 -0
- package/dist/hermes.d.ts +11 -0
- package/dist/hermes.js +41 -0
- package/dist/init.js +86 -7
- package/dist/login.js +5 -2
- package/dist/publish.d.ts +9 -0
- package/dist/publish.js +285 -119
- package/dist/signing.d.ts +1 -0
- package/dist/signing.js +72 -0
- package/dist/sim.js +32 -3
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +15 -0
- package/package.json +2 -2
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>`, `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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/doctor.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/hermes.d.ts
ADDED
|
@@ -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: "
|
|
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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
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) });
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
103
|
-
files.push(rel);
|
|
191
|
+
catch { }
|
|
104
192
|
}
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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");
|
|
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
|
-
|
|
125
|
-
|
|
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"
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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>;
|
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
|
+
}
|
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
|
-
|
|
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
|
}
|
package/dist/verify.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
14
|
+
"@shipvo/schema": "0.0.3"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"typescript": "^5.6.3",
|