@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/README.md +1 -1
- 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 +119 -83
- package/dist/config.d.ts +10 -0
- package/dist/config.js +22 -9
- package/dist/doctor.d.ts +29 -0
- package/dist/doctor.js +210 -0
- package/dist/help.d.ts +8 -0
- package/dist/help.js +76 -0
- package/dist/hermes.d.ts +11 -0
- package/dist/hermes.js +41 -0
- package/dist/init.js +487 -20
- package/dist/login.js +5 -2
- 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 +9 -0
- package/dist/publish.js +168 -33
- package/dist/sim.d.ts +27 -0
- package/dist/sim.js +138 -4
- 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/dist/verify.d.ts +1 -0
- package/dist/verify.js +15 -0
- package/package.json +7 -4
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
|
-
|
|
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 (
|
|
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
|
|
57
|
-
//
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
167
|
+
if ((b.platform ?? "android") === pf)
|
|
168
|
+
bases.set(b.sha256, b.key);
|
|
75
169
|
}
|
|
76
170
|
catch { }
|
|
77
171
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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}
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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/dist/verify.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function verifyPointer(file: string, publicKeyB64: string): boolean;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `shipvo verify <pointer file> [public-key]`: check a release pointer, which is { body, signature }
|
|
2
|
+
// with the signature taken over the body string (see publish.ts). Read-only helper; phones use their own path.
|
|
3
|
+
import * as ed from "@noble/ed25519";
|
|
4
|
+
import { unb64 } from "./index.js";
|
|
5
|
+
export function verifyPointer(file, publicKeyB64) {
|
|
6
|
+
try {
|
|
7
|
+
const p = JSON.parse(file);
|
|
8
|
+
if (typeof p?.body !== "string" || typeof p?.signature !== "string")
|
|
9
|
+
return false;
|
|
10
|
+
return ed.verify(unb64(p.signature), new TextEncoder().encode(p.body), unb64(publicKeyB64));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|