@shipvo/cli 0.0.1 → 0.1.1
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/dist/app.d.ts +1 -0
- package/dist/app.js +62 -0
- package/dist/cli.js +40 -9
- package/dist/config.d.ts +33 -0
- package/dist/config.js +95 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +193 -0
- package/dist/login.d.ts +9 -0
- package/dist/login.js +77 -0
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +153 -0
- package/dist/sim.d.ts +1 -0
- package/dist/sim.js +25 -0
- package/package.json +3 -3
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function appCmd(argv: string[]): Promise<void>;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `shipvo app create`: register this app with the hosted control plane and get a scoped publish token.
|
|
2
|
+
// `shipvo app list`: the apps your account has registered.
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { savedCreds } from "./login.js";
|
|
7
|
+
export async function appCmd(argv) {
|
|
8
|
+
const [sub] = argv;
|
|
9
|
+
const c = savedCreds();
|
|
10
|
+
if (!c) {
|
|
11
|
+
console.error("Not signed in. Run `shipvo login` first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
if (sub === "list") {
|
|
15
|
+
const r = await fetch(`${c.control}/apps`, { headers: { authorization: `Bearer ${c.key}` } });
|
|
16
|
+
const d = await r.json();
|
|
17
|
+
if (!r.ok) {
|
|
18
|
+
console.error(d.hint ?? d.error);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
console.log(d.apps.length ? d.apps.map((a) => `${a.app_id} ${a.name} r${a.latest_release ?? "-"} ${(a.bytes_used / 1048576).toFixed(1)} MB ${a.listed ? (a.approved ? "public" : "listing under review") : "private"}`).join("\n") : "No apps yet. Run `shipvo app create` inside an app made with `shipvo init`.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (sub === "delete") {
|
|
25
|
+
const id = argv[1];
|
|
26
|
+
if (!id || !argv.includes("--yes")) {
|
|
27
|
+
console.error("Usage: shipvo app delete <app-id> --yes (removes the app's published files, tokens and registration; installed shells keep the code they have)");
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
const r = await fetch(`${c.control}/apps/${id}`, { method: "DELETE", headers: { authorization: `Bearer ${c.key}` } });
|
|
31
|
+
const d = await r.json();
|
|
32
|
+
if (!r.ok) {
|
|
33
|
+
console.error(r.status === 404 ? `No app "${id}" on your account.` : d.hint ?? d.error);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
console.log(`Deleted "${id}" (${d.files_deleted} files).`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (sub !== "create") {
|
|
40
|
+
console.error("Usage: shipvo app create | list | delete <id> --yes");
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
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 }) });
|
|
45
|
+
const d = await r.json();
|
|
46
|
+
if (!r.ok) {
|
|
47
|
+
console.error(`Could not register "${cfg.appId}": ${d.message ?? d.hint ?? d.error}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
mkdirSync(join(process.cwd(), ".shipvo"), { recursive: true });
|
|
51
|
+
writeFileSync(join(process.cwd(), ".shipvo", "publish-token"), d.publish_token + "\n", { mode: 0o600 });
|
|
52
|
+
console.log(`${d.created ? "Registered" : "Updated"} "${d.app_id}" (publish token expires ${String(d.expires_at).slice(0, 10)}).`);
|
|
53
|
+
if (cfg.listing?.public)
|
|
54
|
+
console.log(`Listing requested: once reviewed, "${cfg.appId}" appears on shipvo.dev/apps and any signed-in agent can open it. Set listing.public to false in shipvo.json and run this again to withdraw.`);
|
|
55
|
+
else
|
|
56
|
+
console.log(`Private: only your key opens it. To list it publicly, add { "listing": { "public": true, "description": "...", "category": "shop" } } to shipvo.json and run this again.`);
|
|
57
|
+
console.log(`Token saved to .shipvo/publish-token (git-ignored), so \`shipvo publish\` works on this machine now.`);
|
|
58
|
+
console.log(`For CI, add two repository secrets: SHIPVO_PUBLISH_TOKEN (the token) and SHIPVO_SIGNING_SEED (from .shipvo/seed.txt).`);
|
|
59
|
+
if (cfg.controlPlane.replace(/\/$/, "") !== String(d.control_plane).replace(/\/$/, ""))
|
|
60
|
+
console.log(`Note: shipvo.json controlPlane is ${cfg.controlPlane}, but you registered on ${d.control_plane}. Installed shells only trust the one built into them.`);
|
|
61
|
+
console.log(`Agents can open it once published: open_app("${d.app_id}@latest") with your key from \`shipvo mcp\`.`);
|
|
62
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -2,25 +2,56 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { b64, keyFromSeed, sha256Hex, signBytes, signDoc, verifyDoc } from "./index.js";
|
|
5
|
+
import { seed } from "./config.js";
|
|
6
|
+
import { init } from "./init.js";
|
|
7
|
+
import { publish, rollback } from "./publish.js";
|
|
8
|
+
import { sim } from "./sim.js";
|
|
9
|
+
import { keys, login, mcp } from "./login.js";
|
|
10
|
+
import { appCmd } from "./app.js";
|
|
5
11
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
6
|
-
const seed = () => {
|
|
7
|
-
const s = process.env.PURENATIVE_SIGNING_SEED || (process.env.PURENATIVE_SIGNING_SEED_FILE && readFileSync(process.env.PURENATIVE_SIGNING_SEED_FILE, "utf8").trim());
|
|
8
|
-
if (!s) {
|
|
9
|
-
console.error("Set PURENATIVE_SIGNING_SEED or PURENATIVE_SIGNING_SEED_FILE");
|
|
10
|
-
process.exit(2);
|
|
11
|
-
}
|
|
12
|
-
return s;
|
|
13
|
-
};
|
|
14
12
|
const help = `shipvo <command>
|
|
15
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
|
+
|
|
16
23
|
keygen [--out seed.txt] Create an Ed25519 release signing key; prints the public key
|
|
17
|
-
pubkey Print the public key for the seed in
|
|
24
|
+
pubkey Print the public key for the seed in SHIPVO_SIGNING_SEED
|
|
18
25
|
hash <file> SHA-256 and size of a bundle
|
|
19
26
|
sign-file <file> Ed25519 signature (base64) over a file's bytes
|
|
20
27
|
sign-json <file.json> Sign a JSON document (canonical form); writes signature into it
|
|
21
28
|
verify-json <file.json> <public-key-b64>
|
|
22
29
|
`;
|
|
23
30
|
switch (cmd) {
|
|
31
|
+
case "init":
|
|
32
|
+
await init(rest);
|
|
33
|
+
break;
|
|
34
|
+
case "sim":
|
|
35
|
+
await sim(rest);
|
|
36
|
+
break;
|
|
37
|
+
case "publish":
|
|
38
|
+
await publish(rest);
|
|
39
|
+
break;
|
|
40
|
+
case "rollback":
|
|
41
|
+
await rollback(rest);
|
|
42
|
+
break;
|
|
43
|
+
case "login":
|
|
44
|
+
await login(rest);
|
|
45
|
+
break;
|
|
46
|
+
case "app":
|
|
47
|
+
await appCmd(rest);
|
|
48
|
+
break;
|
|
49
|
+
case "keys":
|
|
50
|
+
await keys();
|
|
51
|
+
break;
|
|
52
|
+
case "mcp":
|
|
53
|
+
mcp();
|
|
54
|
+
break;
|
|
24
55
|
case "keygen": {
|
|
25
56
|
const s = b64(randomBytes(32));
|
|
26
57
|
const out = rest[rest.indexOf("--out") + 1];
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** shipvo.json at the app root: what `shipvo init` writes and every other command reads. */
|
|
2
|
+
export type ShipvoConfig = {
|
|
3
|
+
appId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
shellMajor: number;
|
|
6
|
+
/** Control plane the shell trusts (also in app.json's @shipvo/runtime plugin props). */
|
|
7
|
+
controlPlane: string;
|
|
8
|
+
/** Base64 Ed25519 public key built into the shell. */
|
|
9
|
+
publicKey: string;
|
|
10
|
+
/** Where `shipvo publish` writes: the control plane's Cloudflare KV namespace. */
|
|
11
|
+
kv?: {
|
|
12
|
+
namespaceId: string;
|
|
13
|
+
};
|
|
14
|
+
/** Marketplace listing (hosted control plane): shown to agents and on shipvo.dev once reviewed. */
|
|
15
|
+
listing?: {
|
|
16
|
+
public?: boolean;
|
|
17
|
+
description?: string;
|
|
18
|
+
category?: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export declare function loadConfig(dir?: string): ShipvoConfig;
|
|
22
|
+
export declare const args: (argv: string[]) => Record<string, string | undefined>;
|
|
23
|
+
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
24
|
+
export declare function seed(): string;
|
|
25
|
+
/** The publish token `shipvo app create` issued: SHIPVO_PUBLISH_TOKEN (CI) or .shipvo/publish-token. */
|
|
26
|
+
export declare function publishToken(): string | null;
|
|
27
|
+
/** Where publish/rollback write: your own Cloudflare KV (self-hosted control plane) or the hosted control plane with a publish token. */
|
|
28
|
+
export declare function kv(cfg: ShipvoConfig): {
|
|
29
|
+
get(k: string): Promise<string | null>;
|
|
30
|
+
getBytes(k: string): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
31
|
+
keys(prefix: string): Promise<any>;
|
|
32
|
+
put(k: string, body: BodyInit, type: string): Promise<void>;
|
|
33
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function loadConfig(dir = process.cwd()) {
|
|
4
|
+
const p = join(dir, "shipvo.json");
|
|
5
|
+
if (!existsSync(p))
|
|
6
|
+
throw new Error("No shipvo.json here. Run this inside an app created with `shipvo init`, or create one (see docs/getting-started.md).");
|
|
7
|
+
const c = JSON.parse(readFileSync(p, "utf8"));
|
|
8
|
+
for (const k of ["appId", "controlPlane", "publicKey"])
|
|
9
|
+
if (!c[k])
|
|
10
|
+
throw new Error(`shipvo.json: "${k}" is required`);
|
|
11
|
+
return { shellMajor: 1, name: c.appId, ...c };
|
|
12
|
+
}
|
|
13
|
+
const argPairs = (argv) => Object.fromEntries(argv.reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] === undefined || all[i + 1].startsWith("--") ? "true" : all[i + 1]]] : a), []));
|
|
14
|
+
export const args = (argv) => argPairs(argv);
|
|
15
|
+
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
16
|
+
export function seed() {
|
|
17
|
+
const e = process.env;
|
|
18
|
+
const s = e.SHIPVO_SIGNING_SEED || e.PURENATIVE_SIGNING_SEED
|
|
19
|
+
|| ((e.SHIPVO_SIGNING_SEED_FILE || e.PURENATIVE_SIGNING_SEED_FILE) && readFileSync((e.SHIPVO_SIGNING_SEED_FILE || e.PURENATIVE_SIGNING_SEED_FILE), "utf8").trim());
|
|
20
|
+
const local = (() => { try {
|
|
21
|
+
return readFileSync(join(process.cwd(), ".shipvo", "seed.txt"), "utf8").trim();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "";
|
|
25
|
+
} })(); // what `shipvo init` wrote
|
|
26
|
+
if (!s && local)
|
|
27
|
+
return local;
|
|
28
|
+
if (!s) {
|
|
29
|
+
console.error("Set SHIPVO_SIGNING_SEED or SHIPVO_SIGNING_SEED_FILE (the seed `shipvo keygen` printed), or keep it at .shipvo/seed.txt.");
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
/** The publish token `shipvo app create` issued: SHIPVO_PUBLISH_TOKEN (CI) or .shipvo/publish-token. */
|
|
35
|
+
export function publishToken() {
|
|
36
|
+
if (process.env.SHIPVO_PUBLISH_TOKEN)
|
|
37
|
+
return process.env.SHIPVO_PUBLISH_TOKEN.trim();
|
|
38
|
+
try {
|
|
39
|
+
return readFileSync(join(process.cwd(), ".shipvo", "publish-token"), "utf8").trim() || null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Publishing through the hosted control plane: the same get/put/keys, scoped to this app and signed-pointer checked server side. */
|
|
46
|
+
function hostedKv(cfg, token) {
|
|
47
|
+
const base = `${cfg.controlPlane.replace(/\/$/, "")}/apps/${cfg.appId}/kv`;
|
|
48
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
49
|
+
const path = (k) => `${base}/${k.split("/").map(encodeURIComponent).join("/")}`;
|
|
50
|
+
return {
|
|
51
|
+
async get(k) { const r = await fetch(path(k), { headers: auth }); if (r.status === 401)
|
|
52
|
+
throw new Error("publish token rejected: run `shipvo app create` for a new one"); return r.ok ? r.text() : null; },
|
|
53
|
+
async getBytes(k) { const r = await fetch(path(k), { headers: auth }); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
|
|
54
|
+
async keys(prefix) { const r = await fetch(`${base}?prefix=${encodeURIComponent(prefix)}`, { headers: auth }); return r.ok ? (await r.json()).keys : []; },
|
|
55
|
+
async put(k, body, type) {
|
|
56
|
+
const r = await fetch(path(k), { method: "PUT", headers: { ...auth, "content-type": type }, body });
|
|
57
|
+
if (!r.ok) {
|
|
58
|
+
let m = "";
|
|
59
|
+
try {
|
|
60
|
+
const j = await r.json();
|
|
61
|
+
m = j.message ?? j.error;
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
throw new Error(`publish ${k}: ${r.status} ${m}`);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Where publish/rollback write: your own Cloudflare KV (self-hosted control plane) or the hosted control plane with a publish token. */
|
|
70
|
+
export function kv(cfg) {
|
|
71
|
+
const ACC = process.env.CLOUDFLARE_ACCOUNT_ID, TOKEN = process.env.CLOUDFLARE_API_TOKEN;
|
|
72
|
+
const NS = process.env.SHIPVO_KV_ID || process.env.PURENATIVE_KV_ID || cfg.kv?.namespaceId;
|
|
73
|
+
if (!(ACC && TOKEN && NS)) {
|
|
74
|
+
const pt = publishToken();
|
|
75
|
+
if (pt)
|
|
76
|
+
return hostedKv(cfg, pt);
|
|
77
|
+
console.error("Nothing to publish with. Hosted: run `shipvo login`, then `shipvo app create` (or set SHIPVO_PUBLISH_TOKEN). Self-hosted: set CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN and SHIPVO_KV_ID.");
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
const url = (k) => `https://api.cloudflare.com/client/v4/accounts/${ACC}/storage/kv/namespaces/${NS}/values/${encodeURIComponent(k)}`;
|
|
81
|
+
const auth = { Authorization: `Bearer ${TOKEN}` };
|
|
82
|
+
return {
|
|
83
|
+
async get(k) { const r = await fetch(url(k), { headers: auth }); return r.ok ? r.text() : null; },
|
|
84
|
+
async getBytes(k) { const r = await fetch(url(k), { headers: auth }); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
|
|
85
|
+
async keys(prefix) {
|
|
86
|
+
const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${ACC}/storage/kv/namespaces/${NS}/keys?prefix=${encodeURIComponent(prefix)}&limit=1000`, { headers: auth });
|
|
87
|
+
return r.ok ? (await r.json()).result.map((k) => k.name) : [];
|
|
88
|
+
},
|
|
89
|
+
async put(k, body, type) {
|
|
90
|
+
const r = await fetch(url(k), { method: "PUT", headers: { ...auth, "content-type": type }, body });
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
throw new Error(`KV put ${k}: ${r.status} ${await r.text()}`);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(argv: string[]): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `shipvo init <dir>`: a new Expo app wired to Shipvo: signed OTA runtime + plugin, the device API, the simulator,
|
|
2
|
+
// a publish workflow and a shipvo.json. Everything an agent needs to open it is in place on the first commit.
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { b64, keyFromSeed } from "./index.js";
|
|
7
|
+
import { args as parse } from "./config.js";
|
|
8
|
+
const HOSTED = "https://api.shipvo.dev";
|
|
9
|
+
const SDK = { expo: "~58.0.21", camera: "~58.0.14", clipboard: "~58.0.4", localAuth: "~58.0.2", location: "~58.0.20", notifications: "~58.1.7", statusBar: "~58.0.4", constants: "~58.0.0", metroRuntime: "~58.0.15", react: "19.3.0", rn: "0.88.1", rnw: "^0.21.4" };
|
|
10
|
+
const title = (s) => s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
11
|
+
export async function init(argv) {
|
|
12
|
+
const [dirArg] = argv.filter((x) => !x.startsWith("--") && !argv[argv.indexOf(x) - 1]?.startsWith("--"));
|
|
13
|
+
const a = parse(argv);
|
|
14
|
+
if (!dirArg) {
|
|
15
|
+
console.error("Usage: shipvo init <dir> [--app-id myshop] [--name \"My Shop\"] [--control-plane URL] [--public-key B64] [--bundle-id com.example.myshop]");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
const dir = resolve(dirArg);
|
|
19
|
+
if (existsSync(dir)) {
|
|
20
|
+
console.error(`${dir} already exists`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const appId = (a["app-id"] || dirArg.split("/").pop().toLowerCase().replace(/[^a-z0-9-]/g, "-")).slice(0, 32);
|
|
24
|
+
const name = a.name || title(appId);
|
|
25
|
+
const controlPlane = (a["control-plane"] || HOSTED).replace(/\/$/, "");
|
|
26
|
+
const bundleId = a["bundle-id"] || `dev.shipvo.${appId.replace(/-/g, "")}`;
|
|
27
|
+
// Signing key: use the one given, or make one. The seed never enters the repo; the public key is pinned in the shell.
|
|
28
|
+
let publicKey = a["public-key"], seedNote = "";
|
|
29
|
+
if (!publicKey) {
|
|
30
|
+
const seed = b64(randomBytes(32));
|
|
31
|
+
publicKey = b64(keyFromSeed(seed).publicKey);
|
|
32
|
+
mkdirSync(join(dir, ".shipvo"), { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, ".shipvo", "seed.txt"), seed, { mode: 0o600 });
|
|
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
|
+
}
|
|
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");
|
|
38
|
+
w("package.json", JSON.stringify({
|
|
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" },
|
|
41
|
+
dependencies: {
|
|
42
|
+
"@expo/metro-runtime": SDK.metroRuntime, expo: SDK.expo, "expo-camera": SDK.camera, "expo-clipboard": SDK.clipboard, "expo-constants": SDK.constants,
|
|
43
|
+
"expo-local-authentication": SDK.localAuth, "expo-location": SDK.location, "expo-notifications": SDK.notifications, "expo-status-bar": SDK.statusBar,
|
|
44
|
+
react: SDK.react, "react-dom": SDK.react, "react-native": SDK.rn, "react-native-web": SDK.rnw,
|
|
45
|
+
"@shipvo/device": "^0.1.0", "@shipvo/runtime": "^0.1.0",
|
|
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" },
|
|
48
|
+
}, null, 2) + "\n");
|
|
49
|
+
w("app.json", JSON.stringify({ expo: {
|
|
50
|
+
name, slug: appId, version: "1.0.0", orientation: "portrait", userInterfaceStyle: "automatic",
|
|
51
|
+
ios: { bundleIdentifier: bundleId, supportsTablet: false },
|
|
52
|
+
android: { package: bundleId, versionCode: 1, predictiveBackGestureEnabled: false },
|
|
53
|
+
web: { output: "single" },
|
|
54
|
+
plugins: [
|
|
55
|
+
["@shipvo/runtime", { baseUrl: controlPlane, publicKey, appId, shellMajor: 1 }],
|
|
56
|
+
["expo-camera", { cameraPermission: `${name} uses the camera to scan codes.` }],
|
|
57
|
+
["expo-location", { locationWhenInUsePermission: `${name} uses your location to find what is near you.` }],
|
|
58
|
+
["expo-local-authentication", { faceIDPermission: `${name} uses Face ID to confirm actions.` }],
|
|
59
|
+
["expo-notifications", {}],
|
|
60
|
+
],
|
|
61
|
+
} }, null, 2) + "\n");
|
|
62
|
+
w("tsconfig.json", JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true, allowImportingTsExtensions: true, noEmit: true }, exclude: ["node_modules"] }, null, 2) + "\n");
|
|
63
|
+
w("metro.config.js", `// Shipvo: PN_SIM=1 web builds use the simulated device (system sheets in the accessibility tree) instead of Expo's native modules.
|
|
64
|
+
const { getDefaultConfig } = require("expo/metro-config");
|
|
65
|
+
const withShipvoSim = require("@shipvo/device/metro");
|
|
66
|
+
module.exports = withShipvoSim(getDefaultConfig(__dirname));
|
|
67
|
+
`);
|
|
68
|
+
w("index.ts", `import { registerRootComponent } from "expo";\nimport App from "./App";\nregisterRootComponent(App);\n`);
|
|
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
|
+
w("src/release.ts", `// Edit and push: the publish workflow signs and ships this to every installed phone and to the simulator.
|
|
71
|
+
export const RELEASE = { headline: "Release 1: hello from ${name}.", note: "Edit src/release.ts and push to main." };
|
|
72
|
+
`);
|
|
73
|
+
w("App.tsx", APP_TSX(name, appId));
|
|
74
|
+
w(".github/workflows/publish.yml", WORKFLOW);
|
|
75
|
+
w("README.md", README(name, appId, controlPlane));
|
|
76
|
+
console.log(`
|
|
77
|
+
Created ${name} in ${dirArg}
|
|
78
|
+
|
|
79
|
+
cd ${dirArg}
|
|
80
|
+
npm install # or pnpm / bun
|
|
81
|
+
npx playwright install chromium
|
|
82
|
+
npm run sim # the app in the simulator on http://localhost:4545
|
|
83
|
+
npx -y @shipvo/mcp # point Claude / Cursor / ChatGPT at it and open_app("${appId}")
|
|
84
|
+
|
|
85
|
+
Phones: npx expo run:android (needs Android Studio once; the OTA runtime is Android-first)
|
|
86
|
+
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
|
+
(self-hosting instead? see docs/self-hosting.md)
|
|
88
|
+
|
|
89
|
+
${seedNote}
|
|
90
|
+
Public key pinned in the shell: ${publicKey}
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
const APP_TSX = (name, appId) => `import { useEffect, useRef, useState } from "react";
|
|
94
|
+
import { AppState, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
95
|
+
import { StatusBar } from "expo-status-bar";
|
|
96
|
+
import { Runtime } from "@shipvo/runtime";
|
|
97
|
+
import { device } from "@shipvo/device";
|
|
98
|
+
import { ScanHost } from "@shipvo/device/scan";
|
|
99
|
+
import { RELEASE } from "./src/release";
|
|
100
|
+
|
|
101
|
+
const POLL_MS = 10_000;
|
|
102
|
+
|
|
103
|
+
export default function App() {
|
|
104
|
+
const [state] = useState(() => { try { return Runtime.getState(); } catch { return null; } });
|
|
105
|
+
const [log, setLog] = useState<string[]>([]);
|
|
106
|
+
const busy = useRef(false);
|
|
107
|
+
const add = (t: string) => setLog((l) => [t, ...l].slice(0, 20));
|
|
108
|
+
|
|
109
|
+
// Over-the-air updates: tell the runtime the first screen rendered (so this release is kept), then poll while open.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
try { const h = Runtime.markHealthy(); add(\`Running release \${h.release}\`); } catch {}
|
|
112
|
+
const check = async () => {
|
|
113
|
+
if (busy.current || !state?.baseUrl) return; busy.current = true;
|
|
114
|
+
try { const r = await Runtime.checkForUpdate(true); if (r.status === "staged") add(\`Applied release \${r.release} (\${r.via ?? "full"})\`); }
|
|
115
|
+
finally { busy.current = false; }
|
|
116
|
+
};
|
|
117
|
+
check(); const id = setInterval(() => { if (AppState.currentState === "active") check(); }, POLL_MS);
|
|
118
|
+
return () => clearInterval(id);
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
// Device API: real on phones, simulated in the simulator (agents answer the system sheets and set what the camera sees).
|
|
122
|
+
const scan = async () => { try { const r = await device.camera.scan({ prompt: "Point at a code" }); add(r ? \`Scanned \${r.data}\` : "Scan cancelled"); } catch (e: any) { add(String(e.message)); } };
|
|
123
|
+
const locate = async () => { try { const c = await device.location.current(); add(\`At \${c.latitude.toFixed(4)}, \${c.longitude.toFixed(4)}\`); } catch (e: any) { add(String(e.message)); } };
|
|
124
|
+
const notify = async () => { await device.notifications.show({ title: "${name}", body: "Hello from a notification.", data: {} }); add("Notification sent"); };
|
|
125
|
+
const confirm = async () => { const r = await device.auth.confirm("Confirm it is you"); add(r.ok ? "Confirmed" : \`Not confirmed (\${r.reason})\`); };
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<View style={s.root}>
|
|
129
|
+
<StatusBar style="dark" />
|
|
130
|
+
<ScrollView contentContainerStyle={s.page}>
|
|
131
|
+
<Text style={s.h1} accessibilityRole="header">${name}</Text>
|
|
132
|
+
<View style={s.hero}><Text style={s.heroText}>{RELEASE.headline}</Text><Text style={s.heroNote}>{RELEASE.note}</Text></View>
|
|
133
|
+
<Text style={s.h2} accessibilityRole="header">Try the device</Text>
|
|
134
|
+
<Btn label="Scan a code" onPress={scan} />
|
|
135
|
+
<Btn label="Use my location" onPress={locate} />
|
|
136
|
+
<Btn label="Confirm with fingerprint" onPress={confirm} />
|
|
137
|
+
<Btn label="Send a notification" onPress={notify} />
|
|
138
|
+
<Text style={s.h2} accessibilityRole="header">Log</Text>
|
|
139
|
+
{log.length === 0 ? <Text style={s.muted}>Nothing yet.</Text> : log.map((l, i) => <Text key={i} style={s.line}>{l}</Text>)}
|
|
140
|
+
<Text style={s.muted}>App id ${appId} · shell {state?.shellVersion ?? "none"} · release {state?.runningRelease ?? 0}</Text>
|
|
141
|
+
</ScrollView>
|
|
142
|
+
<ScanHost accent="#1F4E5F" />
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Btn({ label, onPress }: { label: string; onPress: () => void }) {
|
|
148
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={label} onPress={onPress} style={s.btn}><Text style={s.btnText}>{label}</Text></Pressable>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const s = StyleSheet.create({
|
|
152
|
+
root: { flex: 1, backgroundColor: "#F6F8F7" },
|
|
153
|
+
page: { padding: 20, paddingTop: 56, gap: 12 },
|
|
154
|
+
h1: { fontSize: 28, fontWeight: "800", color: "#111" }, h2: { fontSize: 17, fontWeight: "700", color: "#111", marginTop: 8 },
|
|
155
|
+
hero: { backgroundColor: "#1F4E5F", borderRadius: 16, padding: 20, gap: 6 },
|
|
156
|
+
heroText: { color: "#fff", fontSize: 20, fontWeight: "800" }, heroNote: { color: "#fff", opacity: 0.9 },
|
|
157
|
+
btn: { minHeight: 48, borderRadius: 10, borderWidth: 1, borderColor: "#CBD5D1", alignItems: "center", justifyContent: "center", backgroundColor: "#fff" },
|
|
158
|
+
btnText: { fontSize: 16, fontWeight: "700", color: "#111" },
|
|
159
|
+
line: { fontSize: 14, color: "#111" }, muted: { fontSize: 13, color: "#5B6B66" },
|
|
160
|
+
});
|
|
161
|
+
`;
|
|
162
|
+
const WORKFLOW = `name: publish
|
|
163
|
+
on:
|
|
164
|
+
push:
|
|
165
|
+
branches: [main]
|
|
166
|
+
paths: ["src/**", "App.tsx"]
|
|
167
|
+
workflow_dispatch:
|
|
168
|
+
jobs:
|
|
169
|
+
publish:
|
|
170
|
+
runs-on: ubuntu-latest
|
|
171
|
+
concurrency: shipvo-publish
|
|
172
|
+
if: \${{ github.event_name != 'push' || !contains(github.event.head_commit.message, '[no-ota]') }}
|
|
173
|
+
steps:
|
|
174
|
+
- uses: actions/checkout@v4
|
|
175
|
+
- uses: actions/setup-node@v4
|
|
176
|
+
with: { node-version: 22 }
|
|
177
|
+
- run: npm ci
|
|
178
|
+
- run: pip install --break-system-packages bsdiff4
|
|
179
|
+
- name: Build, sign and publish over the air
|
|
180
|
+
env:
|
|
181
|
+
SHIPVO_PUBLISH_TOKEN: \${{ secrets.SHIPVO_PUBLISH_TOKEN }} # from \`shipvo app create\`; self-hosting instead: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, SHIPVO_KV_ID
|
|
182
|
+
SHIPVO_SIGNING_SEED: \${{ secrets.SHIPVO_SIGNING_SEED }}
|
|
183
|
+
run: npx shipvo publish | tee -a "$GITHUB_STEP_SUMMARY"
|
|
184
|
+
`;
|
|
185
|
+
const README = (name, appId, cp) => `# ${name}
|
|
186
|
+
|
|
187
|
+
A [Shipvo](https://github.com/shipvo/shipvo) app: one React Native codebase that people install from the stores and AI agents operate in a simulator, kept current with signed over-the-air releases.
|
|
188
|
+
|
|
189
|
+
- \`npm run sim\` opens it in the simulator; \`npx -y @shipvo/mcp\` lets Claude, Cursor or ChatGPT use it (\`open_app("${appId}")\`).
|
|
190
|
+
- \`npx expo run:android\` builds the native shell (the trust root and control plane \`${cp}\` are pinned in it).
|
|
191
|
+
- Push to \`main\`: the publish workflow signs the new JavaScript and every phone and simulator gets it.
|
|
192
|
+
- \`src/release.ts\` is the first thing to edit; \`App.tsx\` shows the device API (camera, location, biometrics, notifications).
|
|
193
|
+
`;
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function savedCreds(): {
|
|
2
|
+
key: string;
|
|
3
|
+
control: string;
|
|
4
|
+
mcp_url: string;
|
|
5
|
+
login?: string;
|
|
6
|
+
} | null;
|
|
7
|
+
export declare function login(argv: string[]): Promise<void>;
|
|
8
|
+
export declare function keys(): Promise<void>;
|
|
9
|
+
export declare function mcp(): void;
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// `shipvo login`: sign in with GitHub (device flow) and get an API key for the hosted simulator.
|
|
2
|
+
// `shipvo keys`: show the stored key's status. `shipvo mcp`: print the MCP config for agents.
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { args as parse } from "./config.js";
|
|
7
|
+
const HOSTED = "https://api.shipvo.dev";
|
|
8
|
+
const credFile = () => join(process.env.SHIPVO_HOME || join(homedir(), ".shipvo"), "credentials.json");
|
|
9
|
+
export function savedCreds() {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(credFile(), "utf8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function login(argv) {
|
|
18
|
+
const a = parse(argv);
|
|
19
|
+
const control = (a["control-plane"] || process.env.SHIPVO_CONTROL || HOSTED).replace(/\/$/, "");
|
|
20
|
+
const start = await fetch(`${control}/auth/device/start`, { method: "POST" });
|
|
21
|
+
const s = await start.json();
|
|
22
|
+
if (!start.ok) {
|
|
23
|
+
console.error(`Login is not available on ${control}: ${s.message ?? s.error}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
console.log(`\nOpen ${s.verification_uri} and enter the code:\n\n ${s.user_code}\n\nWaiting for GitHub…`);
|
|
27
|
+
const deadline = Date.now() + (s.expires_in ?? 900) * 1000;
|
|
28
|
+
let interval = (s.interval ?? 5) * 1000;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
31
|
+
const r = await fetch(`${control}/auth/device/poll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ device_code: s.device_code }) });
|
|
32
|
+
const d = await r.json();
|
|
33
|
+
if (r.status === 202) {
|
|
34
|
+
if (d.status === "slow_down")
|
|
35
|
+
interval += 5000;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!r.ok) {
|
|
39
|
+
console.error(`Login failed: ${d.status ?? d.error}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(join(credFile(), ".."), { recursive: true });
|
|
43
|
+
writeFileSync(credFile(), JSON.stringify({ key: d.key, control, mcp_url: d.mcp.url, login: d.login, apps: d.apps, saved_at: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
44
|
+
console.log(`\nSigned in as ${d.login}. Key ${d.prefix}… saved to ${credFile()} (quota: ${d.quota_minutes} sandbox minutes; expires ${String(d.expires_at).slice(0, 10)}; apps: ${d.apps.join(", ")}).\n`);
|
|
45
|
+
printMcp(d.mcp.url, d.key);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.error("Timed out waiting for GitHub.");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
export async function keys() {
|
|
52
|
+
const c = savedCreds();
|
|
53
|
+
if (!c) {
|
|
54
|
+
console.error("Not signed in. Run `shipvo login`.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const r = await fetch(`${c.control}/auth/me`, { headers: { authorization: `Bearer ${c.key}` } });
|
|
58
|
+
const d = await r.json();
|
|
59
|
+
if (!r.ok) {
|
|
60
|
+
console.error(d.error === "key_expired" ? `Key ${c.key.slice(0, 12)}… expired on ${d.expires_at}. Run \`shipvo login\` to get a new one.` : `Key ${c.key.slice(0, 12)}… is not valid any more (${d.error}). Run \`shipvo login\`.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
console.log(JSON.stringify({ login: d.github_login, prefix: d.prefix, scope: d.scope, app: d.app_id, expires_at: d.expires_at, used_minutes: d.used_minutes, quota_minutes: d.quota_minutes, last_used_at: d.last_used_at, mcp_url: c.mcp_url }, null, 2));
|
|
64
|
+
}
|
|
65
|
+
export function mcp() {
|
|
66
|
+
const c = savedCreds();
|
|
67
|
+
if (!c) {
|
|
68
|
+
console.error("Not signed in. Run `shipvo login`.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
printMcp(c.mcp_url, c.key);
|
|
72
|
+
}
|
|
73
|
+
function printMcp(url, key) {
|
|
74
|
+
console.log(`Claude Desktop / Cursor / Claude Code (remote):\n${JSON.stringify({ mcpServers: { shipvo: { url, headers: { Authorization: `Bearer ${key}` } } } }, null, 2)}\n`);
|
|
75
|
+
console.log(`ChatGPT (custom MCP connector, cannot send headers): ${url}/${key}\n`);
|
|
76
|
+
console.log(`Local simulator instead: { "mcpServers": { "shipvo": { "command": "npx", "args": ["-y", "@shipvo/mcp"] } } } after \`shipvo sim\`.`);
|
|
77
|
+
}
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// `shipvo publish`: build the Hermes bundle, sign a release pointer, publish bundle + patches + the agent-facing web
|
|
2
|
+
// artifact to the control plane. `shipvo rollback --to N`: republish an earlier bundle as a new release.
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { sha256Hex, signBytes } from "./index.js";
|
|
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" };
|
|
10
|
+
const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
|
|
11
|
+
async function currentRelease(kv, app, major) {
|
|
12
|
+
let max = 0;
|
|
13
|
+
for (const b of ["b0", "b1", "b2"]) {
|
|
14
|
+
const raw = await kv.get(`p/${app}/${major}/${b}`);
|
|
15
|
+
if (raw)
|
|
16
|
+
try {
|
|
17
|
+
max = Math.max(max, JSON.parse(JSON.parse(raw).body).release);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
}
|
|
21
|
+
return max;
|
|
22
|
+
}
|
|
23
|
+
export async function publish(argv) {
|
|
24
|
+
const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
|
|
25
|
+
const APP = cfg.appId, MAJOR = cfg.shellMajor;
|
|
26
|
+
const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
|
|
27
|
+
const bands = (a.bands || "b0,b1,b2").split(",");
|
|
28
|
+
const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
|
|
29
|
+
if (a["skip-export"] !== "true")
|
|
30
|
+
execSync("npx expo export --platform android --output-dir dist-ota --clear", { stdio: "inherit" });
|
|
31
|
+
const jsDir = "dist-ota/_expo/static/js/android";
|
|
32
|
+
const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
|
|
33
|
+
if (!file)
|
|
34
|
+
throw new Error("no bundle in " + jsDir);
|
|
35
|
+
const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
|
|
36
|
+
const name = `r${release}.hbc`, newSha = sha256Hex(bytes);
|
|
37
|
+
await kv.put(`b/${APP}/${name}`, bytes, "application/octet-stream");
|
|
38
|
+
// Patches from recent releases and from each shell's embedded bundle; devices verify the patched result against the signed sha256.
|
|
39
|
+
const MAX_BASES = Number(a["max-bases"] || 6);
|
|
40
|
+
const bases = new Map();
|
|
41
|
+
const hist = (await Promise.all((await kv.keys(`h/${APP}/r`)).map(async (k) => { try {
|
|
42
|
+
return JSON.parse((await kv.get(k)));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
} })))
|
|
47
|
+
.filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
|
|
48
|
+
for (const h of hist)
|
|
49
|
+
bases.set(h.sha256, `b/${APP}/r${h.release}.hbc`);
|
|
50
|
+
for (const k of await kv.keys(`base/${APP}/`)) {
|
|
51
|
+
try {
|
|
52
|
+
const b = JSON.parse((await kv.get(k)));
|
|
53
|
+
bases.set(b.sha256, b.key);
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
}
|
|
57
|
+
const patches = [];
|
|
58
|
+
let bsdiff = true;
|
|
59
|
+
try {
|
|
60
|
+
execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
bsdiff = false;
|
|
64
|
+
console.error("bsdiff4 not installed (pip install bsdiff4): publishing full bundles only");
|
|
65
|
+
}
|
|
66
|
+
if (bsdiff) {
|
|
67
|
+
const work = mkdtempSync(join(tmpdir(), "shipvo-"));
|
|
68
|
+
writeFileSync(join(work, "new.hbc"), bytes);
|
|
69
|
+
for (const [fromSha, key] of bases) {
|
|
70
|
+
if (fromSha === newSha)
|
|
71
|
+
continue;
|
|
72
|
+
const base = await kv.getBytes(key);
|
|
73
|
+
if (!base)
|
|
74
|
+
continue;
|
|
75
|
+
writeFileSync(join(work, "old.hbc"), base);
|
|
76
|
+
execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
|
|
77
|
+
const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
|
|
78
|
+
if (patch.length >= bytes.length * 0.6)
|
|
79
|
+
continue;
|
|
80
|
+
const pkey = `b/${APP}/d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
|
|
81
|
+
await kv.put(pkey, patch, "application/octet-stream");
|
|
82
|
+
patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
|
|
86
|
+
// The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator.
|
|
87
|
+
let web = null;
|
|
88
|
+
if (a["skip-web"] !== "true") {
|
|
89
|
+
execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
|
|
90
|
+
const base = `w/${APP}/r${release}`;
|
|
91
|
+
const files = [];
|
|
92
|
+
let indexSha = "";
|
|
93
|
+
for (const f of walk("dist-web")) {
|
|
94
|
+
const rel = f.slice("dist-web/".length);
|
|
95
|
+
if (rel === "metadata.json")
|
|
96
|
+
continue;
|
|
97
|
+
let body = readFileSync(f);
|
|
98
|
+
if (rel === "index.html") {
|
|
99
|
+
body = Buffer.from(body.toString("utf8").replace(/(src|href)="\//g, `$1="/${base}/`));
|
|
100
|
+
indexSha = sha256Hex(new Uint8Array(body));
|
|
101
|
+
}
|
|
102
|
+
await kv.put(`${base}/${rel}`, new Uint8Array(body), MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream");
|
|
103
|
+
files.push(rel);
|
|
104
|
+
}
|
|
105
|
+
web = { base: `/${base}/`, entry: "index.html", sha256: indexSha, files: files.length };
|
|
106
|
+
const idxKey = `w/${APP}/releases`;
|
|
107
|
+
let idx = { releases: [] };
|
|
108
|
+
try {
|
|
109
|
+
idx = JSON.parse((await kv.get(idxKey))) ?? idx;
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
|
|
113
|
+
idx.latest = Math.max(...idx.releases);
|
|
114
|
+
await kv.put(idxKey, JSON.stringify(idx), "application/json");
|
|
115
|
+
console.error(`web artifact: ${files.length} files at /${base}/`);
|
|
116
|
+
}
|
|
117
|
+
const issued_at = new Date().toISOString();
|
|
118
|
+
for (const band of bands) {
|
|
119
|
+
const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, release, issued_at,
|
|
120
|
+
core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
|
|
121
|
+
addons: [], screens: [], min_shell_version: MIN_SHELL, ...(web ? { web } : {}) });
|
|
122
|
+
await kv.put(`p/${APP}/${MAJOR}/${band}`, JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
|
|
123
|
+
}
|
|
124
|
+
await kv.put(`h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, web }), "application/json");
|
|
125
|
+
console.log(JSON.stringify({ app: APP, release, bands, bytes: bytes.length, min_shell_version: MIN_SHELL, web, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) }));
|
|
126
|
+
}
|
|
127
|
+
export async function rollback(argv) {
|
|
128
|
+
const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
|
|
129
|
+
const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
|
|
130
|
+
if (!to) {
|
|
131
|
+
console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2]");
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
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
|
+
const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
|
|
140
|
+
let minShell = "1.0.0", web = undefined;
|
|
141
|
+
try {
|
|
142
|
+
const h = JSON.parse((await kv.get(`h/${APP}/r${to}`)));
|
|
143
|
+
minShell = h.min_shell_version || minShell;
|
|
144
|
+
web = h.web ?? undefined;
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
for (const band of (a.bands || "b0,b1,b2").split(",")) {
|
|
148
|
+
const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, release, issued_at,
|
|
149
|
+
core: { version: `1.0.${release}`, sha256: sha256Hex(bytes), size: bytes.length, url: `b/${APP}/r${to}.hbc` }, addons: [], screens: [], min_shell_version: minShell, ...(web ? { web } : {}) });
|
|
150
|
+
await kv.put(`p/${APP}/${MAJOR}/${band}`, JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
|
|
151
|
+
}
|
|
152
|
+
console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
|
|
153
|
+
}
|
package/dist/sim.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sim(argv: string[]): Promise<void>;
|
package/dist/sim.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// `shipvo sim`: build the app's web bundle with the simulated device and serve it in the simulator.
|
|
2
|
+
import { execSync, spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { args as parse, loadConfig } from "./config.js";
|
|
6
|
+
export async function sim(argv) {
|
|
7
|
+
const a = parse(argv);
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
|
|
10
|
+
console.error(`Building ${cfg.name} for the simulator…`);
|
|
11
|
+
execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
|
|
12
|
+
}
|
|
13
|
+
let server;
|
|
14
|
+
try {
|
|
15
|
+
server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
console.error("Install @shipvo/sim (and playwright) in this app: npm i -D @shipvo/sim playwright && npx playwright install chromium");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
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
|
+
const p = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId, ...(a.dir ? ["--dir", a.dir] : [])], { stdio: "inherit", env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
|
|
24
|
+
p.on("exit", (c) => process.exit(c ?? 0));
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"vitest": "^2.1.4",
|
|
19
19
|
"@types/node": "^22.8.0"
|
|
20
20
|
},
|
|
21
|
-
"description": "Shipvo CLI:
|
|
21
|
+
"description": "Shipvo CLI: create an app (init), run it in the simulator (sim), sign and publish over-the-air releases (publish, rollback), manage signing keys.",
|
|
22
22
|
"keywords": [
|
|
23
23
|
"react-native",
|
|
24
24
|
"expo",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"node": ">=20"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
|
-
"build": "tsc -p tsconfig.json",
|
|
61
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
62
62
|
"test": "vitest run"
|
|
63
63
|
}
|
|
64
64
|
}
|