@shipvo/cli 0.1.2 → 0.1.4

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/publish.js CHANGED
@@ -1,10 +1,13 @@
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 { existsSync, 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, relative, resolve } from "node:path";
7
7
  import { sha256Hex, signBytes } from "./index.js";
8
+ import { checkBundle, parseDeclared } from "./hermes.js";
9
+ import { requiredNativeModules } from "./native.js";
10
+ import { checkPublishPolicy } from "./policy.js";
8
11
  import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
9
12
  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
13
  const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
@@ -33,33 +36,123 @@ const platformsOf = (a) => {
33
36
  return [p];
34
37
  throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
35
38
  };
39
+ /** Unknown flags are errors: a typo like --dryrun must not fall through to a real publish. */
40
+ function checkFlags(argv, known) {
41
+ const bad = argv.filter((x) => x.startsWith("--") && !known.includes(x.slice(2)));
42
+ if (bad.length) {
43
+ const eq = bad.find((x) => x.includes("="));
44
+ const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
45
+ throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
46
+ }
47
+ }
48
+ /** An on/off flag: bare or "true" is on, "false" is off. Anything else is an error, so a typo never flips it silently. */
49
+ function switchFlag(a, name) {
50
+ const v = a[name];
51
+ if (v === undefined || v === "false")
52
+ return false;
53
+ if (v === "true")
54
+ return true;
55
+ throw new Error(`--${name} is an on/off flag: use --${name}, --${name} true or --${name} false (got "${v}")`);
56
+ }
57
+ /** 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. */
58
+ function localKv(dir) {
59
+ const root = resolve(dir);
60
+ const mem = new Map();
61
+ const enc = new TextEncoder();
62
+ return {
63
+ async get(k) { const v = mem.get(k); return v ? new TextDecoder().decode(v) : null; },
64
+ async getBytes(k) { return mem.get(k) ?? null; },
65
+ async keys(prefix) { return [...mem.keys()].filter((k) => k.startsWith(prefix)); },
66
+ async put(k, body, _type) {
67
+ const f = resolve(root, k);
68
+ if (!f.startsWith(root + "/"))
69
+ throw new Error(`refusing to write outside ${dir}: ${k}`);
70
+ const bytes = typeof body === "string" ? enc.encode(body) : new Uint8Array(body);
71
+ mkdirSync(dirname(f), { recursive: true });
72
+ writeFileSync(f, bytes);
73
+ mem.set(k, bytes);
74
+ },
75
+ };
76
+ }
77
+ const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "out"];
78
+ export const PUBLISH_HELP = `Usage: shipvo publish [flags]
79
+
80
+ Build, sign and publish an over-the-air release.
81
+
82
+ Flags (write a value after a space, not =; on/off flags can be bare, or true/false):
83
+ --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)
84
+ --platform android|ios|all Platform to publish (default android)
85
+ --release N Release number (default: the next one)
86
+ --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)
87
+ --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98
88
+ --max-bases N How many older releases to build patches from (default 6)
89
+ --skip-export Reuse the last build in dist-ota/
90
+ --skip-web Do not publish the web copy for the simulator
91
+ --dry-run Build and sign, write files locally, send nothing
92
+ --out <dir> Where --dry-run writes files (default .shipvo/dry-run)
93
+ --help Show this help
94
+ `;
36
95
  export async function publish(argv) {
37
- const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
96
+ if (argv.includes("--help") || argv.includes("-h")) {
97
+ console.log(PUBLISH_HELP);
98
+ return;
99
+ }
100
+ checkFlags(argv, PUBLISH_FLAGS);
101
+ const a = parse(argv), cfg = loadConfig(), SEED = seed();
102
+ const dry = switchFlag(a, "dry-run"), skipExport = switchFlag(a, "skip-export"), skipWeb = switchFlag(a, "skip-web");
103
+ if (a.out !== undefined && !dry)
104
+ throw new Error("--out only works with --dry-run");
105
+ if (a.out === "true")
106
+ throw new Error("--out needs a directory: pass --out <dir>");
107
+ const outDir = a.out || ".shipvo/dry-run";
108
+ const kv = dry ? localKv(outDir) : kvFor(cfg);
38
109
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
39
110
  const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
111
+ if (a["hermes-bytecode-version"] === "true")
112
+ throw new Error("--hermes-bytecode-version needs a value: pass a value like 96 (or 96,98)");
113
+ const declaredHermes = parseDeclared(a["hermes-bytecode-version"] ?? cfg.hermesBytecodeVersion);
114
+ const skippedNative = [];
115
+ const nativeModules = requiredNativeModules(".", skippedNative);
116
+ if (skippedNative.length)
117
+ console.error(`native modules not recorded (the range is not a plain numeric semver range, so the control plane cannot check it): ${skippedNative.join(", ")}`);
118
+ if (dry)
119
+ console.error(`native modules this release requires: ${nativeModules ? Object.entries(nativeModules).map(([n, r]) => `${n}@${r}`).join(", ") : "none found"}`);
40
120
  const bands = (a.bands || "b0,b1,b2").split(",");
121
+ if (dry) {
122
+ const verdict = checkPublishPolicy(cfg.policy, bands);
123
+ if (!verdict.ok) {
124
+ console.error(`Refused by policy rule ${verdict.rule}: ${verdict.reason}`);
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ }
41
129
  const platforms = platformsOf(a);
42
130
  const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
43
- const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL };
131
+ const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL, ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(dry ? { dry_run: true, out: outDir } : {}) };
132
+ if (dry)
133
+ 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)"}.`);
134
+ let totalBytes = 0;
135
+ const patchInfo = { bases: 0, bsdiff: false, patches: 0 };
44
136
  for (const pf of platforms) {
45
137
  // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
46
138
  const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
47
- if (a["skip-export"] !== "true")
139
+ if (!skipExport)
48
140
  execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: "inherit" });
49
141
  const jsDir = `${root}/_expo/static/js/${pf}`;
50
142
  const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
51
143
  if (!file)
52
144
  throw new Error("no bundle in " + jsDir);
53
145
  const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
146
+ const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
54
147
  const name = bundleName(release, pf), newSha = sha256Hex(bytes);
55
148
  await kv.put(`b/${APP}/${name}`, bytes, "application/octet-stream");
56
- // Patches (Android): from recent releases and each shell's embedded bundle; devices verify the patched result against the signed sha256.
57
- // iOS ships full bundles for now: the iOS runtime does not apply patches yet.
149
+ // Patches: from recent releases and each shell's embedded bundle; devices verify the patched result against the signed sha256.
150
+ // Releases are per platform. Embedded-bundle records are shared under base/<app>/ and tagged with a platform (no tag means Android).
58
151
  const patches = [];
59
- if (pf === "android") {
152
+ {
60
153
  const MAX_BASES = Number(a["max-bases"] || 6);
61
154
  const bases = new Map();
62
- const hist = (await Promise.all((await kv.keys(`h/${APP}/r`)).map(async (k) => { try {
155
+ const hist = (await Promise.all((await kv.keys(pf === "ios" ? `h/${APP}/ios-r` : `h/${APP}/r`)).map(async (k) => { try {
63
156
  return JSON.parse((await kv.get(k)));
64
157
  }
65
158
  catch {
@@ -67,21 +160,25 @@ export async function publish(argv) {
67
160
  } })))
68
161
  .filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
69
162
  for (const h of hist)
70
- bases.set(h.sha256, `b/${APP}/r${h.release}.hbc`);
163
+ bases.set(h.sha256, `b/${APP}/${bundleName(h.release, pf)}`);
71
164
  for (const k of await kv.keys(`base/${APP}/`)) {
72
165
  try {
73
166
  const b = JSON.parse((await kv.get(k)));
74
- bases.set(b.sha256, b.key);
167
+ if ((b.platform ?? "android") === pf)
168
+ bases.set(b.sha256, b.key);
75
169
  }
76
170
  catch { }
77
171
  }
78
- let bsdiff = true;
79
- try {
80
- execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
81
- }
82
- catch {
83
- bsdiff = false;
84
- console.error("bsdiff4 not installed (pip install bsdiff4): publishing full bundles only");
172
+ // Only look for bsdiff4 when there is an older bundle to patch from; a first release needs no patches.
173
+ let bsdiff = false;
174
+ if (bases.size > 0) {
175
+ try {
176
+ execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
177
+ bsdiff = true;
178
+ }
179
+ catch {
180
+ 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.");
181
+ }
85
182
  }
86
183
  if (bsdiff) {
87
184
  const work = mkdtempSync(join(tmpdir(), "shipvo-"));
@@ -97,19 +194,22 @@ export async function publish(argv) {
97
194
  const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
98
195
  if (patch.length >= bytes.length * 0.6)
99
196
  continue;
100
- const pkey = `b/${APP}/d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
197
+ const pkey = `b/${APP}/${pf === "ios" ? "ios-" : ""}d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
101
198
  await kv.put(pkey, patch, "application/octet-stream");
102
199
  patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
103
200
  }
104
201
  }
202
+ patchInfo.bases = bases.size;
203
+ patchInfo.bsdiff = bsdiff;
204
+ patchInfo.patches = patches.length;
105
205
  console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
106
206
  }
107
207
  // The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator (built once, with the first platform).
108
208
  let web = null;
109
- if (a["skip-web"] !== "true" && pf === platforms[0]) {
110
- web = await publishWeb(kv, cfg, APP, release);
209
+ if (!skipWeb && pf === platforms[0]) {
210
+ web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined);
111
211
  }
112
- else if (a["skip-web"] !== "true") {
212
+ else if (!skipWeb) {
113
213
  try {
114
214
  web = JSON.parse((await kv.get(`h/${APP}/r${release}`)))?.web ?? null;
115
215
  }
@@ -119,11 +219,12 @@ export async function publish(argv) {
119
219
  for (const band of bands) {
120
220
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
121
221
  core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
122
- addons: [], screens: [], min_shell_version: MIN_SHELL, ...(web ? { web } : {}) });
222
+ addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(web ? { web } : {}) });
123
223
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
124
224
  }
125
- await kv.put(pf === "ios" ? `h/${APP}/ios-r${release}` : `h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, web }), "application/json");
126
- summary[pf] = { bytes: bytes.length, ...(pf === "android" ? { patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) } : {}) };
225
+ 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 } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), web }), "application/json");
226
+ totalBytes += bytes.length;
227
+ summary[pf] = { bytes: bytes.length, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) };
127
228
  if (web)
128
229
  summary.web = web;
129
230
  }
@@ -132,9 +233,34 @@ export async function publish(argv) {
132
233
  Object.assign(summary, { bytes: an.bytes, patches: an.patches });
133
234
  delete summary.android;
134
235
  }
135
- console.log(JSON.stringify(summary));
236
+ if (dry)
237
+ console.log(dryRunSummary(release, totalBytes, outDir, patchInfo));
238
+ else
239
+ console.log(JSON.stringify(summary));
240
+ }
241
+ /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
242
+ export function formatSize(bytes) {
243
+ return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
244
+ }
245
+ /** The dry-run result in plain words. `info` says what happened with patches on Android. */
246
+ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff: false, patches: 0 }) {
247
+ const lines = [
248
+ `Dry run complete. Nothing was sent.`,
249
+ ` Release: ${release}`,
250
+ ` Size: ${formatSize(bytes)}`,
251
+ ` Files: ${outDir}`,
252
+ ];
253
+ if (info.bases === 0 && release > 1)
254
+ lines.push(`Patches are not built in a dry run, because older releases are not read. Publish for real to build them.`);
255
+ else if (info.bases === 0)
256
+ lines.push(`Full bundles are fine for a first release. Small patches matter once you have older releases to compare against.`);
257
+ else if (!info.bsdiff)
258
+ lines.push(`Patches were skipped because bsdiff4 is not installed (pip install bsdiff4). Full bundles still work.`);
259
+ else
260
+ lines.push(`Patches built: ${info.patches}.`);
261
+ return lines.join("\n");
136
262
  }
137
- async function publishWeb(kv, cfg, APP, release) {
263
+ async function publishWeb(kv, cfg, APP, release, outDir) {
138
264
  execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
139
265
  const base = `w/${APP}/r${release}`;
140
266
  const files = [];
@@ -161,7 +287,9 @@ async function publishWeb(kv, cfg, APP, release) {
161
287
  idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
162
288
  idx.latest = Math.max(...idx.releases);
163
289
  await kv.put(idxKey, JSON.stringify(idx), "application/json");
164
- console.error(`web artifact: ${files.length} files at /${base}/`);
290
+ // Show where the files are on disk, relative to this folder: the URL path (/w/...) is not a folder.
291
+ const where = outDir ? (relative(process.cwd(), resolve(outDir, base)) || ".") + "/" : `/${base}/`;
292
+ console.error(`web artifact: ${files.length} files at ${where}`);
165
293
  // Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
166
294
  const L = cfg.listing ?? {};
167
295
  const store = `w/${APP}/store`;
@@ -186,15 +314,17 @@ async function publishWeb(kv, cfg, APP, release) {
186
314
  return web;
187
315
  }
188
316
  export async function rollback(argv) {
189
- const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
317
+ checkFlags(argv, ["to", "bands", "platform", "dry-run"]);
318
+ const a = parse(argv), dry = a["dry-run"] !== undefined, cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
190
319
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
191
320
  if (!to) {
192
- console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all]");
321
+ console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all] [--dry-run]");
193
322
  process.exit(2);
194
323
  }
195
324
  const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
196
325
  let minShell = "1.0.0";
197
- for (const pf of platformsOf(a)) {
326
+ const platforms = platformsOf(a), bands = (a.bands || "b0,b1,b2").split(",");
327
+ for (const pf of platforms) {
198
328
  const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
199
329
  if (!bytes) {
200
330
  console.error(`Release ${to} ${pf} bundle not found`);
@@ -207,11 +337,16 @@ export async function rollback(argv) {
207
337
  web = h.web ?? undefined;
208
338
  }
209
339
  catch { }
210
- for (const band of (a.bands || "b0,b1,b2").split(",")) {
340
+ for (const band of bands) {
211
341
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
212
342
  core: { version: `1.0.${release}`, sha256: sha256Hex(bytes), size: bytes.length, url: `b/${APP}/${bundleName(to, pf)}` }, addons: [], screens: [], min_shell_version: minShell, ...(web ? { web } : {}) });
213
- await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
343
+ if (!dry)
344
+ await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
214
345
  }
215
346
  }
347
+ if (dry) {
348
+ console.log([`Rollback preview for ${APP}:`, ` Rolling back to the code of release ${to}`, ` New release number: ${release}`, ` Platforms: ${platforms.join(", ")}`, ` Bands: ${bands.join(", ")}`, ` Min shell version: ${minShell}`, `Dry run: nothing was written.`].join("\n"));
349
+ return;
350
+ }
216
351
  console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
217
352
  }
package/dist/sim.d.ts CHANGED
@@ -1 +1,28 @@
1
+ /** True if nothing is listening on the port (checks by binding, then closing). */
2
+ export declare function portFree(port: number): Promise<boolean>;
3
+ /** True if a path (relative to the project) is inside a folder that never triggers a rebuild. */
4
+ export declare const ignoredPath: (rel: string) => boolean;
5
+ /** Turns a build error into a short plain message. Unknown errors keep their first line only. */
6
+ export declare function friendlyBuildError(message: string, verbose?: boolean): string;
7
+ /**
8
+ * Calls `build` once after changes settle for `debounceMs`. A change during a build queues one more build.
9
+ * `build` rejects with the build error; it is printed and the caller keeps serving the last good build.
10
+ * Returns a function that stops watching.
11
+ */
12
+ export declare function watchAndRebuild(opts: {
13
+ dir: string;
14
+ build: () => Promise<void>;
15
+ debounceMs?: number;
16
+ log?: (line: string) => void;
17
+ verbose?: boolean;
18
+ }): () => void;
19
+ /**
20
+ * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
21
+ * The old build is moved aside first and moved back if the swap fails.
22
+ * `command` is the shell command; it gets the output dir as its last argument.
23
+ */
24
+ export declare function rebuildInto(opts?: {
25
+ root?: string;
26
+ command?: string;
27
+ }): Promise<void>;
1
28
  export declare function sim(argv: string[]): Promise<void>;
package/dist/sim.js CHANGED
@@ -1,11 +1,139 @@
1
1
  // `shipvo sim`: build the app's web bundle with the simulated device and serve it in the simulator.
2
2
  import { execSync, spawn } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, mkdirSync, renameSync, rmSync, watch } from "node:fs";
4
+ import { join } from "node:path";
4
5
  import { createRequire } from "node:module";
6
+ import { createServer, connect } from "node:net";
5
7
  import { args as parse, loadConfig } from "./config.js";
8
+ /** True if nothing is listening on the port (checks by binding, then closing). */
9
+ export function portFree(port) {
10
+ return new Promise((resolve) => {
11
+ const s = createServer();
12
+ s.once("error", () => resolve(false));
13
+ s.listen(port, () => s.close(() => resolve(true)));
14
+ });
15
+ }
16
+ /** Resolves true once something accepts connections on the port, false if `stop` says to give up. */
17
+ function waitListening(port, stop) {
18
+ return new Promise((resolve) => {
19
+ const tryOnce = () => {
20
+ if (stop())
21
+ return resolve(false);
22
+ const c = connect(port, "127.0.0.1");
23
+ c.once("connect", () => { c.destroy(); resolve(true); });
24
+ c.once("error", () => { c.destroy(); setTimeout(tryOnce, 100); });
25
+ };
26
+ tryOnce();
27
+ });
28
+ }
29
+ const IGNORED = new Set(["node_modules", "sim-dist", ".shipvo", ".git", ".expo", "dist", "dist-ota", "web-build", "ios", "android"]);
30
+ /** True if a path (relative to the project) is inside a folder that never triggers a rebuild. */
31
+ export const ignoredPath = (rel) => rel.split(/[\\/]/).some((seg) => IGNORED.has(seg));
32
+ /** Turns a build error into a short plain message. Unknown errors keep their first line only. */
33
+ export function friendlyBuildError(message, verbose = false) {
34
+ if (verbose)
35
+ return `build failed, still serving the last good build: ${message}`;
36
+ const m = /in file (\S+\.json) at (\d+):(\d+)/.exec(message);
37
+ if (m && /Unexpected token|SyntaxError/.test(message)) {
38
+ const comma = /expected punc [«"']?,/.test(message) || /Unexpected token string/.test(message);
39
+ const what = comma ? "check for a missing or extra comma" : "a character is missing, extra or in the wrong place";
40
+ return `Your file ${m[1]} has a mistake at line ${m[2]}, column ${m[3]}: ${what}. The page still shows your last good version.`;
41
+ }
42
+ return `build failed, still serving the last good build: ${message.split("\n")[0]} (run with --verbose for details)`;
43
+ }
44
+ /**
45
+ * Calls `build` once after changes settle for `debounceMs`. A change during a build queues one more build.
46
+ * `build` rejects with the build error; it is printed and the caller keeps serving the last good build.
47
+ * Returns a function that stops watching.
48
+ */
49
+ export function watchAndRebuild(opts) {
50
+ const { dir, build, debounceMs = 300, log = (l) => console.error(l), verbose = false } = opts;
51
+ let timer;
52
+ let running = false;
53
+ let again = false;
54
+ let closed = false;
55
+ const run = async () => {
56
+ if (running) {
57
+ again = true;
58
+ return;
59
+ }
60
+ running = true;
61
+ const t0 = Date.now();
62
+ try {
63
+ await build();
64
+ log(`rebuilt in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
65
+ }
66
+ catch (e) {
67
+ log(friendlyBuildError(e.message, verbose));
68
+ }
69
+ running = false;
70
+ if (again && !closed) {
71
+ again = false;
72
+ void run();
73
+ }
74
+ };
75
+ const w = watch(dir, { recursive: true }, (_ev, file) => {
76
+ if (closed || (file && ignoredPath(String(file))))
77
+ return;
78
+ clearTimeout(timer);
79
+ timer = setTimeout(() => void run(), debounceMs);
80
+ });
81
+ const stop = () => { closed = true; clearTimeout(timer); w.close(); };
82
+ w.on("error", (e) => { log(`file watcher failed, no longer watching: ${e.message}`); stop(); });
83
+ return stop;
84
+ }
85
+ /**
86
+ * Builds into `<root>/.shipvo/sim-next` and swaps it into `<root>/sim-dist` only if the build succeeds.
87
+ * The old build is moved aside first and moved back if the swap fails.
88
+ * `command` is the shell command; it gets the output dir as its last argument.
89
+ */
90
+ export function rebuildInto(opts = {}) {
91
+ const root = opts.root ?? process.cwd();
92
+ const command = opts.command ?? "npx expo export --platform web --clear --output-dir";
93
+ const next = join(root, ".shipvo", "sim-next"), live = join(root, "sim-dist"), prev = join(root, ".shipvo", "sim-prev");
94
+ return new Promise((resolve, reject) => {
95
+ mkdirSync(join(root, ".shipvo"), { recursive: true });
96
+ rmSync(next, { recursive: true, force: true });
97
+ const p = spawn(`${command} "${next}"`, { cwd: root, shell: true, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "pipe"] });
98
+ let tail = "";
99
+ p.stderr.on("data", (d) => { tail = (tail + d).slice(-4000); });
100
+ p.on("error", (e) => { rmSync(next, { recursive: true, force: true }); reject(e); });
101
+ p.on("close", (code) => {
102
+ if (code !== 0) {
103
+ rmSync(next, { recursive: true, force: true });
104
+ return reject(new Error(tail.trim().split("\n").slice(-5).join("\n") || `build exited with code ${code}`));
105
+ }
106
+ let moved = false;
107
+ try {
108
+ rmSync(prev, { recursive: true, force: true });
109
+ if (existsSync(live)) {
110
+ renameSync(live, prev);
111
+ moved = true;
112
+ }
113
+ renameSync(next, live);
114
+ }
115
+ catch (e) {
116
+ try {
117
+ if (moved && !existsSync(live))
118
+ renameSync(prev, live);
119
+ }
120
+ catch { /* keep the original error */ }
121
+ rmSync(next, { recursive: true, force: true });
122
+ return reject(e);
123
+ }
124
+ rmSync(prev, { recursive: true, force: true });
125
+ resolve();
126
+ });
127
+ });
128
+ }
6
129
  export async function sim(argv) {
7
130
  const a = parse(argv);
8
131
  const cfg = loadConfig();
132
+ const port = a.port || "4545";
133
+ if (!(await portFree(Number(port)))) {
134
+ console.error(`Port ${port} is in use. Stop the other \`shipvo sim\` or pass --port <n>.`);
135
+ process.exit(1);
136
+ }
9
137
  if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
10
138
  console.error(`Building ${cfg.name} for the simulator…`);
11
139
  execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
@@ -18,8 +146,14 @@ export async function sim(argv) {
18
146
  console.error("Install @shipvo/sim (and playwright) in this app: npm i -D @shipvo/sim playwright && npx playwright install chromium");
19
147
  process.exit(2);
20
148
  }
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
149
  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));
150
+ let exited = false;
151
+ p.on("exit", (c) => { exited = true; process.exit(c ?? 0); });
152
+ if (await waitListening(Number(port), () => exited)) {
153
+ console.error(`Simulator on http://localhost:${port}/app/ (open_app("${cfg.appId}") from @shipvo/mcp; Ctrl-C to stop)`);
154
+ if (a.watch === "true") {
155
+ watchAndRebuild({ dir: process.cwd(), build: () => rebuildInto(), verbose: a.verbose === "true" });
156
+ console.error("Watching for changes (ignoring node_modules, sim-dist, .shipvo, .expo, dist, dist-ota, web-build, ios, android)");
157
+ }
158
+ }
25
159
  }
@@ -0,0 +1,16 @@
1
+ type Row = {
2
+ release: number;
3
+ events: Record<string, number>;
4
+ apply_ms_median: number | null;
5
+ cold_start_ms_median: number | null;
6
+ };
7
+ type AutoHalt = {
8
+ threshold: number;
9
+ min_reports: number;
10
+ } | null;
11
+ /** Plain-text table: one row per release, one column per event name, then the two medians. */
12
+ export declare function formatStatus(releases: Row[]): string;
13
+ /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
14
+ export declare function describeAutoHalt(c: AutoHalt | undefined): string;
15
+ export declare function status(argv: string[], fetchFn?: typeof fetch): Promise<number>;
16
+ export {};
package/dist/status.js ADDED
@@ -0,0 +1,54 @@
1
+ import { args, loadConfig } from "./config.js";
2
+ const ms = (v) => (typeof v === "number" ? String(Math.round(v)) : "-");
3
+ /** Plain-text table: one row per release, one column per event name, then the two medians. */
4
+ export function formatStatus(releases) {
5
+ const names = [...new Set(releases.flatMap((r) => Object.keys(r.events ?? {})))].sort();
6
+ const rows = [["release", ...names, "apply ms", "cold start ms"],
7
+ ...releases.map((r) => [String(r.release), ...names.map((n) => String(r.events?.[n] ?? 0)), ms(r.apply_ms_median), ms(r.cold_start_ms_median)])];
8
+ const w = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
9
+ return rows.map((r) => r.map((c, i) => (i === 0 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(" ")).join("\n");
10
+ }
11
+ /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
12
+ export function describeAutoHalt(c) {
13
+ if (!c || typeof c.threshold !== "number")
14
+ return "auto-halt: off";
15
+ const n = c.min_reports ?? 20;
16
+ return `auto-halt: on at ${Math.round(c.threshold * 1000) / 10}% after ${n} report${n === 1 ? "" : "s"}`;
17
+ }
18
+ export async function status(argv, fetchFn = fetch) {
19
+ const a = args(argv);
20
+ const cfg = loadConfig();
21
+ const appId = a.app && a.app !== "true" ? a.app : cfg.appId;
22
+ const base = cfg.controlPlane.replace(/\/$/, "");
23
+ const url = `${base}/t/${encodeURIComponent(appId)}/summary`;
24
+ let data;
25
+ try {
26
+ const r = await fetchFn(url);
27
+ if (!r.ok)
28
+ throw new Error(`HTTP ${r.status}`);
29
+ data = (await r.json());
30
+ }
31
+ catch (e) {
32
+ console.error(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
33
+ return 1;
34
+ }
35
+ if (argv.includes("--json")) {
36
+ console.log(JSON.stringify(data));
37
+ return 0;
38
+ }
39
+ const releases = data.releases ?? [];
40
+ // An older control plane has no auto_halt field: say nothing rather than a wrong "off".
41
+ if ("auto_halt" in data)
42
+ console.log(describeAutoHalt(data.auto_halt));
43
+ if (!releases.length) {
44
+ console.log(`No telemetry yet for "${appId}".`);
45
+ return 0;
46
+ }
47
+ const h = data.halted;
48
+ if (h && typeof h === "object") {
49
+ const pct = Math.round(h.threshold * 1000) / 10;
50
+ console.log(`Release ${h.release} is halted: ${h.failed} of ${h.reports} devices failed (limit ${pct}%). Publish a new release or run shipvo rollback to clear.`);
51
+ }
52
+ console.log(formatStatus(releases));
53
+ return 0;
54
+ }
@@ -0,0 +1,2 @@
1
+ /** `shipvo store details --dry-run [--out dir]`: print the answers, write two files. Nothing is uploaded; no credentials are read. */
2
+ export declare function store(argv: string[]): Promise<void>;
package/dist/store.js ADDED
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { deriveStoreDetails, renderDataSafetyCsv, renderPrivacyPolicy } from "@shipvo/store";
4
+ import { args, loadConfig } from "./config.js";
5
+ /** `shipvo store details --dry-run [--out dir]`: print the answers, write two files. Nothing is uploaded; no credentials are read. */
6
+ export async function store(argv) {
7
+ const [sub, ...rest] = argv;
8
+ if (sub !== "details") {
9
+ console.error("Usage: shipvo store details --dry-run [--out dir]");
10
+ process.exit(2);
11
+ }
12
+ const a = args(rest);
13
+ if (a["dry-run"] === undefined || a["dry-run"] === "false") {
14
+ console.error("shipvo store details: upload is not available yet; use --dry-run");
15
+ process.exit(2);
16
+ }
17
+ const cfg = loadConfig();
18
+ const d = deriveStoreDetails(cfg);
19
+ const out = a.out && a.out !== "true" ? a.out : ".shipvo/store";
20
+ mkdirSync(out, { recursive: true });
21
+ writeFileSync(join(out, "data-safety.csv"), renderDataSafetyCsv(d));
22
+ writeFileSync(join(out, "privacy-policy.md"), renderPrivacyPolicy(d));
23
+ console.log(`Data safety answers for ${d.appName} (derived from shipvo.json):`);
24
+ if (d.rows.length === 0)
25
+ console.log(" No declared capability touches user data.");
26
+ for (const r of d.rows)
27
+ console.log(` ${r.dataType}: collected ${r.collected}, shared ${r.shared}, purpose ${r.purpose}`);
28
+ if (d.fill.total > 0)
29
+ console.log(`Filled without you: ${d.fill.filled} of ${d.fill.total} fields.`);
30
+ console.log(`\nYou still need to answer (${d.unknown.length}):`);
31
+ for (const u of d.unknown)
32
+ console.log(` - ${u.question}`);
33
+ console.log(`\nWrote ${join(out, "data-safety.csv")} and ${join(out, "privacy-policy.md")}. Nothing was uploaded.`);
34
+ }
@@ -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
+ }