@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/adopt-bare.d.ts +39 -0
- package/dist/adopt-bare.js +236 -0
- package/dist/adopt.d.ts +26 -0
- package/dist/adopt.js +257 -0
- package/dist/autohalt.d.ts +5 -0
- package/dist/autohalt.js +84 -0
- package/dist/cli.js +114 -88
- package/dist/config.d.ts +8 -0
- package/dist/config.js +22 -9
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +108 -7
- package/dist/help.d.ts +4 -2
- package/dist/help.js +17 -2
- package/dist/init.js +483 -16
- package/dist/native.d.ts +3 -0
- package/dist/native.js +39 -0
- package/dist/policy.d.ts +18 -0
- package/dist/policy.js +33 -0
- package/dist/preview.d.ts +35 -0
- package/dist/preview.js +249 -0
- package/dist/promote.d.ts +9 -0
- package/dist/promote.js +126 -0
- package/dist/publish.d.ts +1 -1
- package/dist/publish.js +61 -28
- package/dist/sim.d.ts +27 -0
- package/dist/sim.js +108 -3
- package/dist/status.d.ts +16 -0
- package/dist/status.js +54 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +34 -0
- package/package.json +7 -4
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
|
|
79
|
-
--skip-web
|
|
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
|
|
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 (
|
|
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
|
|
125
|
-
//
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/status.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/store.d.ts
ADDED
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
|
+
"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
|
-
"
|
|
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": [
|