@shipvo/cli 0.1.3 → 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
@@ -3,9 +3,11 @@
3
3
  import { execSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { dirname, join, resolve } from "node:path";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
7
  import { sha256Hex, signBytes } from "./index.js";
8
8
  import { checkBundle, parseDeclared } from "./hermes.js";
9
+ import { requiredNativeModules } from "./native.js";
10
+ import { checkPublishPolicy } from "./policy.js";
9
11
  import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
10
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" };
11
13
  const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
@@ -43,6 +45,15 @@ function checkFlags(argv, known) {
43
45
  throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
44
46
  }
45
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
+ }
46
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. */
47
58
  function localKv(dir) {
48
59
  const root = resolve(dir);
@@ -68,15 +79,15 @@ export const PUBLISH_HELP = `Usage: shipvo publish [flags]
68
79
 
69
80
  Build, sign and publish an over-the-air release.
70
81
 
71
- Flags (write a value after a space, not =):
82
+ Flags (write a value after a space, not =; on/off flags can be bare, or true/false):
72
83
  --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)
73
84
  --platform android|ios|all Platform to publish (default android)
74
85
  --release N Release number (default: the next one)
75
86
  --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)
76
87
  --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98
77
88
  --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
89
+ --skip-export Reuse the last build in dist-ota/
90
+ --skip-web Do not publish the web copy for the simulator
80
91
  --dry-run Build and sign, write files locally, send nothing
81
92
  --out <dir> Where --dry-run writes files (default .shipvo/dry-run)
82
93
  --help Show this help
@@ -88,7 +99,7 @@ export async function publish(argv) {
88
99
  }
89
100
  checkFlags(argv, PUBLISH_FLAGS);
90
101
  const a = parse(argv), cfg = loadConfig(), SEED = seed();
91
- const dry = a["dry-run"] !== undefined; // a plain switch: any use turns it on
102
+ const dry = switchFlag(a, "dry-run"), skipExport = switchFlag(a, "skip-export"), skipWeb = switchFlag(a, "skip-web");
92
103
  if (a.out !== undefined && !dry)
93
104
  throw new Error("--out only works with --dry-run");
94
105
  if (a.out === "true")
@@ -100,10 +111,24 @@ export async function publish(argv) {
100
111
  if (a["hermes-bytecode-version"] === "true")
101
112
  throw new Error("--hermes-bytecode-version needs a value: pass a value like 96 (or 96,98)");
102
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"}`);
103
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
+ }
104
129
  const platforms = platformsOf(a);
105
130
  const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
106
- const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL, ...(dry ? { dry_run: true, out: outDir } : {}) };
131
+ const summary = { app: APP, release, bands, min_shell_version: MIN_SHELL, ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(dry ? { dry_run: true, out: outDir } : {}) };
107
132
  if (dry)
108
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)"}.`);
109
134
  let totalBytes = 0;
@@ -111,7 +136,7 @@ export async function publish(argv) {
111
136
  for (const pf of platforms) {
112
137
  // Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
113
138
  const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
114
- if (a["skip-export"] !== "true")
139
+ if (!skipExport)
115
140
  execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: "inherit" });
116
141
  const jsDir = `${root}/_expo/static/js/${pf}`;
117
142
  const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
@@ -121,8 +146,8 @@ export async function publish(argv) {
121
146
  const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
122
147
  const name = bundleName(release, pf), newSha = sha256Hex(bytes);
123
148
  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.
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).
126
151
  const patches = [];
127
152
  {
128
153
  const MAX_BASES = Number(a["max-bases"] || 6);
@@ -136,14 +161,14 @@ export async function publish(argv) {
136
161
  .filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
137
162
  for (const h of hist)
138
163
  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)));
164
+ for (const k of await kv.keys(`base/${APP}/`)) {
165
+ try {
166
+ const b = JSON.parse((await kv.get(k)));
167
+ if ((b.platform ?? "android") === pf)
143
168
  bases.set(b.sha256, b.key);
144
- }
145
- catch { }
146
169
  }
170
+ catch { }
171
+ }
147
172
  // Only look for bsdiff4 when there is an older bundle to patch from; a first release needs no patches.
148
173
  let bsdiff = false;
149
174
  if (bases.size > 0) {
@@ -181,10 +206,10 @@ export async function publish(argv) {
181
206
  }
182
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).
183
208
  let web = null;
184
- if (a["skip-web"] !== "true" && pf === platforms[0]) {
185
- web = await publishWeb(kv, cfg, APP, release);
209
+ if (!skipWeb && pf === platforms[0]) {
210
+ web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined);
186
211
  }
187
- else if (a["skip-web"] !== "true") {
212
+ else if (!skipWeb) {
188
213
  try {
189
214
  web = JSON.parse((await kv.get(`h/${APP}/r${release}`)))?.web ?? null;
190
215
  }
@@ -194,10 +219,10 @@ export async function publish(argv) {
194
219
  for (const band of bands) {
195
220
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
196
221
  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 } : {}) });
222
+ addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(web ? { web } : {}) });
198
223
  await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
199
224
  }
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");
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");
201
226
  totalBytes += bytes.length;
202
227
  summary[pf] = { bytes: bytes.length, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) };
203
228
  if (web)
@@ -235,7 +260,7 @@ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff:
235
260
  lines.push(`Patches built: ${info.patches}.`);
236
261
  return lines.join("\n");
237
262
  }
238
- async function publishWeb(kv, cfg, APP, release) {
263
+ async function publishWeb(kv, cfg, APP, release, outDir) {
239
264
  execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
240
265
  const base = `w/${APP}/r${release}`;
241
266
  const files = [];
@@ -262,7 +287,9 @@ async function publishWeb(kv, cfg, APP, release) {
262
287
  idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
263
288
  idx.latest = Math.max(...idx.releases);
264
289
  await kv.put(idxKey, JSON.stringify(idx), "application/json");
265
- 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}`);
266
293
  // Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
267
294
  const L = cfg.listing ?? {};
268
295
  const store = `w/${APP}/store`;
@@ -287,16 +314,17 @@ async function publishWeb(kv, cfg, APP, release) {
287
314
  return web;
288
315
  }
289
316
  export async function rollback(argv) {
290
- checkFlags(argv, ["to", "bands", "platform"]);
291
- 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);
292
319
  const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
293
320
  if (!to) {
294
- 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]");
295
322
  process.exit(2);
296
323
  }
297
324
  const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
298
325
  let minShell = "1.0.0";
299
- for (const pf of platformsOf(a)) {
326
+ const platforms = platformsOf(a), bands = (a.bands || "b0,b1,b2").split(",");
327
+ for (const pf of platforms) {
300
328
  const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
301
329
  if (!bytes) {
302
330
  console.error(`Release ${to} ${pf} bundle not found`);
@@ -309,11 +337,16 @@ export async function rollback(argv) {
309
337
  web = h.web ?? undefined;
310
338
  }
311
339
  catch { }
312
- for (const band of (a.bands || "b0,b1,b2").split(",")) {
340
+ for (const band of bands) {
313
341
  const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
314
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 } : {}) });
315
- 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");
316
345
  }
317
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
+ }
318
351
  console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
319
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,12 @@
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";
5
6
  import { createServer, connect } from "node:net";
6
7
  import { args as parse, loadConfig } from "./config.js";
7
8
  /** True if nothing is listening on the port (checks by binding, then closing). */
8
- function portFree(port) {
9
+ export function portFree(port) {
9
10
  return new Promise((resolve) => {
10
11
  const s = createServer();
11
12
  s.once("error", () => resolve(false));
@@ -25,6 +26,106 @@ function waitListening(port, stop) {
25
26
  tryOnce();
26
27
  });
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
+ }
28
129
  export async function sim(argv) {
29
130
  const a = parse(argv);
30
131
  const cfg = loadConfig();
@@ -49,6 +150,10 @@ export async function sim(argv) {
49
150
  let exited = false;
50
151
  p.on("exit", (c) => { exited = true; process.exit(c ?? 0); });
51
152
  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)`);
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
+ }
53
158
  }
54
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -11,12 +11,15 @@
11
11
  "dependencies": {
12
12
  "@noble/ed25519": "^2.1.0",
13
13
  "@noble/hashes": "^1.5.0",
14
- "@shipvo/schema": "0.0.3"
14
+ "plist": "^3.1.1",
15
+ "@shipvo/schema": "0.0.4",
16
+ "@shipvo/store": "^0.0.1"
15
17
  },
16
18
  "devDependencies": {
19
+ "@types/node": "^22.8.0",
20
+ "@types/plist": "^3.0.5",
17
21
  "typescript": "^5.6.3",
18
- "vitest": "^2.1.4",
19
- "@types/node": "^22.8.0"
22
+ "vitest": "^2.1.4"
20
23
  },
21
24
  "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
25
  "keywords": [