@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.
@@ -0,0 +1,35 @@
1
+ export type PreviewFile = {
2
+ path: string;
3
+ bytes: Uint8Array;
4
+ contentType: string;
5
+ };
6
+ export type PreviewResult = {
7
+ url: string;
8
+ claimUrl: string | null;
9
+ expiresAt: string | null;
10
+ files: number;
11
+ warnings: string[];
12
+ };
13
+ export type PreviewOpts = {
14
+ fetch?: typeof fetch;
15
+ sleep?: (ms: number) => Promise<void>;
16
+ maxRetries?: number;
17
+ };
18
+ export declare const blockedPath: (rel: string) => boolean;
19
+ /** Throws if any file would leak a secret. Names the file and the kind, never the value. */
20
+ export declare function refuseSecrets(files: PreviewFile[], secrets?: string[]): void;
21
+ /** Reads a build folder into upload entries, skipping files that must never be uploaded. */
22
+ export declare function collectFiles(dir: string): PreviewFile[];
23
+ /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches and a back control. */
24
+ export declare function framePage(name: string): string;
25
+ /** Puts the frame page at index.html and the app's own entry at app.html (its asset paths are absolute, so it stays at the site root). */
26
+ export declare function wrapBuild(buildDir: string, name: string): PreviewFile[];
27
+ /** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
28
+ export declare function publishToHereNow(files: PreviewFile[], o?: PreviewOpts): Promise<PreviewResult>;
29
+ export declare const NOTICE = "This link is public: anyone who has it can open the preview. It expires in 24 hours unless you open the claim link and sign in to here.now, which keeps it.";
30
+ /** Builds, wraps and publishes. Used by the CLI and by the MCP `publish_preview` tool (through `shipvo preview --json`). */
31
+ export declare function runPreview(root: string, o?: PreviewOpts & {
32
+ skipBuild?: boolean;
33
+ buildDir?: string;
34
+ }): Promise<PreviewResult>;
35
+ export declare function preview(argv: string[]): Promise<void>;
@@ -0,0 +1,249 @@
1
+ // `shipvo preview`: build the web artifact with the simulated device, wrap it in a phone-sized frame page
2
+ // and publish it to here.now with no account. The link is public and lasts 24 hours unless claimed.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
5
+ import { join, relative, sep, extname } from "node:path";
6
+ import { args as parse, loadConfig } from "./config.js";
7
+ const API = "https://here.now/api/v1";
8
+ const CLIENT = "shipvo/cli";
9
+ const TYPES = {
10
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
11
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
12
+ ".ico": "image/x-icon", ".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".wasm": "application/wasm",
13
+ };
14
+ const typeOf = (p) => TYPES[extname(p).toLowerCase()] ?? "application/octet-stream";
15
+ // Files that never leave the machine, wherever they sit in the tree.
16
+ const BLOCKED_SEGMENTS = new Set([".shipvo", ".git", "node_modules", ".herenow"]);
17
+ const BLOCKED_NAME = /^(\.env.*|.*\.map|.*\.(pem|key|p8|p12|jks|keystore|mobileprovision)|seed\.txt|publish-token|id_rsa|id_ed25519)$/i;
18
+ export const blockedPath = (rel) => {
19
+ const segs = rel.split(/[\\/]/);
20
+ return segs.some((s) => BLOCKED_SEGMENTS.has(s)) || BLOCKED_NAME.test(segs[segs.length - 1]);
21
+ };
22
+ const SECRET_PATTERNS = [
23
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"],
24
+ [/SHIPVO_SIGNING_SEED\s*["']?\s*[:=]/, "SHIPVO_SIGNING_SEED"],
25
+ [/SHIPVO_PUBLISH_TOKEN\s*["']?\s*[:=]/, "SHIPVO_PUBLISH_TOKEN"],
26
+ [/CLOUDFLARE_API_TOKEN\s*["']?\s*[:=]/, "CLOUDFLARE_API_TOKEN"],
27
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}/, "a GitHub token"],
28
+ [/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key"],
29
+ [/\bsk-[A-Za-z0-9_-]{32,}/, "an API key"],
30
+ ];
31
+ const TEXT = /\.(html|js|mjs|css|json|txt|svg)$/i;
32
+ /** Secret values this process knows: they must not appear in anything we upload. */
33
+ function knownSecrets() {
34
+ const e = process.env;
35
+ const out = [e.SHIPVO_SIGNING_SEED, e.PURENATIVE_SIGNING_SEED, e.SHIPVO_PUBLISH_TOKEN, e.CLOUDFLARE_API_TOKEN];
36
+ for (const f of [join(process.cwd(), ".shipvo", "seed.txt"), join(process.cwd(), ".shipvo", "publish-token")]) {
37
+ try {
38
+ out.push(readFileSync(f, "utf8").trim());
39
+ }
40
+ catch { }
41
+ }
42
+ return out.filter((s) => !!s && s.length >= 16);
43
+ }
44
+ /** Throws if any file would leak a secret. Names the file and the kind, never the value. */
45
+ export function refuseSecrets(files, secrets = knownSecrets()) {
46
+ for (const f of files) {
47
+ if (!TEXT.test(f.path))
48
+ continue;
49
+ const text = Buffer.from(f.bytes).toString("utf8");
50
+ for (const s of secrets)
51
+ if (text.includes(s))
52
+ throw new Error(`Refusing to publish: ${f.path} contains a secret value from your environment or .shipvo/. Remove it from the app's web build and run again.`);
53
+ for (const [re, what] of SECRET_PATTERNS)
54
+ if (re.test(text))
55
+ throw new Error(`Refusing to publish: ${f.path} references ${what}. Keep secrets out of the web build and run again.`);
56
+ }
57
+ }
58
+ /** Reads a build folder into upload entries, skipping files that must never be uploaded. */
59
+ export function collectFiles(dir) {
60
+ const out = [];
61
+ const walk = (d) => {
62
+ for (const name of readdirSync(d)) {
63
+ const full = join(d, name), rel = relative(dir, full).split(sep).join("/");
64
+ if (blockedPath(rel))
65
+ continue;
66
+ if (statSync(full).isDirectory())
67
+ walk(full);
68
+ else
69
+ out.push({ path: rel, bytes: new Uint8Array(readFileSync(full)), contentType: typeOf(rel) });
70
+ }
71
+ };
72
+ walk(dir);
73
+ return out;
74
+ }
75
+ /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches and a back control. */
76
+ export function framePage(name) {
77
+ const title = name.replace(/[<>&"']/g, "");
78
+ return `<!doctype html>
79
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
80
+ <title>${title} preview</title>
81
+ <style>
82
+ :root{color-scheme:light}body.dark{color-scheme:dark}
83
+ html,body{margin:0;height:100%;font:14px system-ui,sans-serif;background:#e8eaed;color:#111}
84
+ body.dark{background:#15171a;color:#eee}
85
+ .bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:10px;flex-wrap:wrap}
86
+ .bar button,.back{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
87
+ .bar button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
88
+ .stage{display:flex;justify-content:center;padding:0 10px 20px}
89
+ .device{display:flex;flex-direction:column;background:#000;color:#fff;border-radius:28px;box-shadow:0 8px 30px #0005;overflow:hidden;width:360px;max-width:100%;box-sizing:border-box;padding:0 6px}
90
+ .device[data-skin=iphone]{border-radius:44px;padding:0 10px}
91
+ .status{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 14px;font-size:12px;flex:none}
92
+ .device[data-skin=iphone] .status{height:44px;padding:0 24px}
93
+ .device[data-skin=android] .status .notch{display:none}
94
+ .device[data-skin=iphone] .status .icons{letter-spacing:2px}
95
+ .notch{width:90px;height:24px;background:#000;border-radius:14px;position:absolute}
96
+ .device[data-skin=iphone] .status{position:relative}
97
+ .device[data-skin=iphone] .status .notch{left:50%;margin-left:-45px;top:8px;background:#111}
98
+ iframe{border:0;background:#fff;width:100%;flex:1;min-height:0;display:block}
99
+ .nav{flex:none;display:flex;justify-content:center;align-items:center;height:44px}
100
+ .device[data-skin=iphone] .nav{height:44px}
101
+ .back{color:#fff;border-color:#888;padding:4px 14px}
102
+ .device[data-skin=iphone] .back{display:block;position:relative;width:134px;height:44px;padding:0;border:0;background:transparent;font-size:0}
103
+ .device[data-skin=iphone] .back::after{content:"";position:absolute;left:0;right:0;top:50%;height:5px;margin-top:-2px;border-radius:3px;background:#fff}
104
+ .back:focus-visible{outline:2px solid #fff;outline-offset:2px}
105
+ .device[data-skin=android] .back{border:0}
106
+ .note{text-align:center;opacity:.7;padding:0 12px 16px}
107
+ @media (max-width:500px){.bar,.note{display:none}.stage{padding:0}.device{padding:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;border-radius:0!important;box-shadow:none}}
108
+ </style></head><body>
109
+ <div class="bar" role="toolbar" aria-label="Preview options">
110
+ <button data-skin="android" aria-pressed="true">Android</button><button data-skin="iphone" aria-pressed="false">iPhone</button>
111
+ <button data-size="phone" aria-pressed="true">Phone</button><button data-size="large" aria-pressed="false">Large phone</button><button data-size="tablet" aria-pressed="false">Tablet</button>
112
+ <button id="theme" aria-pressed="false">Dark</button></div>
113
+ <div class="stage"><div class="device" id="device" data-skin="android" style="width:360px;height:760px">
114
+ <div class="status" id="status"><span id="clock">9:41</span><span class="notch"></span><span class="icons" id="icons">&#9679;&#9679; 5G 100%</span></div>
115
+ <iframe id="app" src="app.html" title="${title}"></iframe>
116
+ <div class="nav"><button class="back" id="back" aria-label="Back" title="Back">&#9664; Back</button></div>
117
+ </div></div>
118
+ <p class="note">${title}: simulated device, public preview. System sheets such as permissions and payments are simulated.</p>
119
+ <script>
120
+ var SIZES={phone:[360,760],large:[412,892],tablet:[820,1180]},IPHONE={phone:[390,844],large:[430,932],tablet:[820,1180]};
121
+ var skin="android",size="phone",d=document.getElementById("device"),f=document.getElementById("app"),bk=document.getElementById("back");
122
+ function pressed(sel,val,key){document.querySelectorAll(sel).forEach(function(o){o.setAttribute("aria-pressed",o.dataset[key]===val)})}
123
+ function apply(){var s=(skin==="iphone"?IPHONE:SIZES)[size];d.dataset.skin=skin;d.style.width=s[0]+"px";d.style.height=s[1]+"px";bk.innerHTML=skin==="iphone"?"Back":"&#9664; Back";bk.setAttribute("aria-label","Back");document.getElementById("icons").innerHTML=skin==="iphone"?"5G &#9646; 100%":"&#9679;&#9679; 5G 100%";pressed("[data-skin]",skin,"skin");pressed("[data-size]",size,"size")}
124
+ document.querySelectorAll("[data-skin]").forEach(function(b){b.onclick=function(){skin=b.dataset.skin;apply()}});
125
+ document.querySelectorAll("[data-size]").forEach(function(b){b.onclick=function(){size=b.dataset.size;apply()}});
126
+ // Back does what the simulator's back action does: press the screen's Back button, else go back in history.
127
+ function goBack(){var w=f.contentWindow;try{var doc=w&&w.document;if(!doc)return;var els=doc.querySelectorAll("button,[role=button],a");for(var i=0;i<els.length;i++){var e=els[i],t=(e.getAttribute("aria-label")||e.textContent||"").trim();if(t==="Back"){e.click();return}}w.history.back()}catch(x){}}
128
+ bk.onclick=goBack;
129
+ var t=document.getElementById("theme");t.onclick=function(){var k=!document.body.classList.contains("dark");document.body.classList.toggle("dark",k);t.setAttribute("aria-pressed",k);t.textContent=k?"Light":"Dark";f.style.colorScheme=k?"dark":"light"};
130
+ apply();
131
+ </script></body></html>
132
+ `;
133
+ }
134
+ /** Puts the frame page at index.html and the app's own entry at app.html (its asset paths are absolute, so it stays at the site root). */
135
+ export function wrapBuild(buildDir, name) {
136
+ const files = collectFiles(buildDir).filter((f) => f.path !== "index.html");
137
+ const entry = join(buildDir, "index.html");
138
+ if (!existsSync(entry))
139
+ throw new Error("The web build has no index.html. Run `npx expo export --platform web` and check it works.");
140
+ files.push({ path: "app.html", bytes: new Uint8Array(readFileSync(entry)), contentType: typeOf("x.html") });
141
+ files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name)), contentType: typeOf("x.html") });
142
+ return files;
143
+ }
144
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
145
+ /** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
146
+ export async function publishToHereNow(files, o = {}) {
147
+ const f = o.fetch ?? fetch, sleep = o.sleep ?? realSleep, max = o.maxRetries ?? 3;
148
+ refuseSecrets(files);
149
+ const blocked = files.find((x) => blockedPath(x.path));
150
+ if (blocked)
151
+ throw new Error(`Refusing to upload ${blocked.path}.`);
152
+ const headers = { "content-type": "application/json", "X-HereNow-Client": CLIENT };
153
+ /** One JSON call; on 429 waits retry_after seconds (or Retry-After) and tries again, up to `max` times. */
154
+ const call = async (url, body, what) => {
155
+ for (let attempt = 0;; attempt++) {
156
+ const r = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
157
+ if (r.status === 429 && attempt < max) {
158
+ let j = {};
159
+ try {
160
+ j = await r.json();
161
+ }
162
+ catch { }
163
+ const secs = Number(j.retry_after ?? r.headers.get("retry-after") ?? 5);
164
+ await sleep((Number.isFinite(secs) && secs > 0 ? Math.min(secs, 120) : 5) * 1000);
165
+ continue;
166
+ }
167
+ let j = {};
168
+ try {
169
+ j = await r.json();
170
+ }
171
+ catch { }
172
+ if (!r.ok)
173
+ throw new Error(r.status === 429 ? `here.now is rate limiting this publish (${what}). Wait ${j.retry_after ?? "a few"} seconds and run again.` : `here.now ${what} failed (${r.status}): ${j.message ?? j.error ?? "no details"}`);
174
+ return j;
175
+ }
176
+ };
177
+ const created = await call(`${API}/publish`, { files: files.map((x) => ({ path: x.path, size: x.bytes.length, contentType: x.contentType })) }, "create");
178
+ const slug = created.slug;
179
+ let targets = created.upload.uploads;
180
+ const byPath = new Map(files.map((x) => [x.path, x]));
181
+ const putAll = async (list) => {
182
+ const expired = [];
183
+ for (const t of list) {
184
+ const file = byPath.get(t.path);
185
+ if (!file)
186
+ continue;
187
+ const r = await f(t.url, { method: "PUT", headers: { "content-type": file.contentType, ...(t.headers ?? {}) }, body: file.bytes });
188
+ if (r.status === 403 || r.status === 401 || r.status === 400)
189
+ expired.push(t);
190
+ else if (!r.ok)
191
+ throw new Error(`Upload of ${t.path} failed (${r.status}).`);
192
+ }
193
+ return expired;
194
+ };
195
+ let pending = await putAll(targets);
196
+ for (let i = 0; pending.length && i < 2; i++) {
197
+ const fresh = await call(`${API}/publish/${slug}/uploads/refresh`, { claimToken: created.claimToken }, "refresh upload URLs");
198
+ const want = new Set(pending.map((p) => p.path));
199
+ targets = fresh.upload.uploads.filter((t) => want.has(t.path));
200
+ pending = await putAll(targets);
201
+ }
202
+ if (pending.length)
203
+ throw new Error(`Could not upload ${pending.length} file(s), e.g. ${pending[0].path}. Check that *.r2.cloudflarestorage.com is reachable, then run again.`);
204
+ const done = await call(`${API}/publish/${slug}/finalize`, { versionId: created.upload.versionId }, "finalize");
205
+ const warnings = (done.warnings ?? []).map((w) => (typeof w === "string" ? w : JSON.stringify(w)));
206
+ return { url: done.siteUrl ?? created.siteUrl, claimUrl: created.claimUrl ?? null, expiresAt: created.expiresAt ?? null, files: files.length, warnings };
207
+ }
208
+ function build(root, out) {
209
+ rmSync(out, { recursive: true, force: true });
210
+ mkdirSync(join(root, ".shipvo"), { recursive: true });
211
+ return new Promise((resolve, reject) => {
212
+ const p = spawn("npx", ["expo", "export", "--platform", "web", "--clear", "--output-dir", out], { cwd: root, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "inherit"] });
213
+ p.on("error", reject);
214
+ p.on("exit", (c) => (c === 0 ? resolve() : reject(new Error(`The web build failed (exit ${c}). Run \`npx expo export --platform web\` to see why.`))));
215
+ });
216
+ }
217
+ export const NOTICE = "This link is public: anyone who has it can open the preview. It expires in 24 hours unless you open the claim link and sign in to here.now, which keeps it.";
218
+ /** Builds, wraps and publishes. Used by the CLI and by the MCP `publish_preview` tool (through `shipvo preview --json`). */
219
+ export async function runPreview(root, o = {}) {
220
+ const cfg = loadConfig(root);
221
+ const provider = cfg.preview ?? "here.now";
222
+ if (provider === "control-plane")
223
+ throw new Error('shipvo.json sets "preview": "control-plane". That provider is not built yet. Remove the setting or use "here.now".');
224
+ if (provider !== "here.now")
225
+ throw new Error(`shipvo.json: "preview" must be "here.now" or "control-plane", not "${provider}".`);
226
+ const dir = o.buildDir ?? join(root, ".shipvo", "preview-build");
227
+ if (!o.skipBuild)
228
+ await build(root, dir);
229
+ return publishToHereNow(wrapBuild(dir, cfg.name), o);
230
+ }
231
+ export async function preview(argv) {
232
+ const a = parse(argv);
233
+ const json = a.json === "true";
234
+ if (!json)
235
+ console.error("Building the web app with the simulated device…");
236
+ const r = await runPreview(process.cwd(), { skipBuild: a["skip-build"] === "true" });
237
+ if (json) {
238
+ console.log(JSON.stringify({ url: r.url, claimUrl: r.claimUrl, expiresAt: r.expiresAt, files: r.files, warnings: r.warnings, public: true, notice: NOTICE }));
239
+ return;
240
+ }
241
+ console.log(`Preview: ${r.url}`);
242
+ if (r.claimUrl)
243
+ console.log(`Keep it (claim link): ${r.claimUrl}`);
244
+ if (r.expiresAt)
245
+ console.log(`Expires: ${r.expiresAt}`);
246
+ console.log(NOTICE);
247
+ for (const w of r.warnings)
248
+ console.log(`Warning from here.now: ${w}`);
249
+ }
@@ -0,0 +1,9 @@
1
+ import { kv as kvFor } from "./config.js";
2
+ type Store = Pick<ReturnType<typeof kvFor>, "get" | "put">;
3
+ export declare const PROMOTE_FLAGS: string[];
4
+ export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --force Promote even if the release is halted or has reverts\n --help Show this help\n";
5
+ export declare function promote(argv: string[], deps?: {
6
+ kv?: Store;
7
+ fetchFn?: typeof fetch;
8
+ }): Promise<void>;
9
+ export {};
@@ -0,0 +1,126 @@
1
+ // `shipvo promote`: widen a staged release to more bands. Reads the pointer on the narrowest band that has one,
2
+ // and writes the same release (same bundle, hashes and patches) to the other bands with a fresh issued_at and signature.
3
+ import { signBytes } from "./index.js";
4
+ import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
5
+ const BANDS = ["b0", "b1", "b2"];
6
+ const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
7
+ const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
8
+ export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force"];
9
+ export const PROMOTE_HELP = `Usage: shipvo promote [flags]
10
+
11
+ Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
12
+
13
+ Flags (write a value after a space, not =):
14
+ --to b1,b2 Bands to write (default: every band with no pointer for this release)
15
+ Bands that already hold a newer release are always skipped
16
+ --platform android|ios|all Platform to promote (default android)
17
+ --dry-run Print the plan, write nothing
18
+ --force Promote even if the release is halted or has reverts
19
+ --help Show this help
20
+ `;
21
+ function checkFlags(argv, known) {
22
+ const bad = argv.filter((x) => x.startsWith("--") && !known.includes(x.slice(2)));
23
+ if (bad.length) {
24
+ const eq = bad.find((x) => x.includes("="));
25
+ const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
26
+ throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
27
+ }
28
+ }
29
+ export async function promote(argv, deps = {}) {
30
+ if (argv.includes("--help") || argv.includes("-h")) {
31
+ console.log(PROMOTE_HELP);
32
+ return;
33
+ }
34
+ checkFlags(argv, PROMOTE_FLAGS);
35
+ const a = parse(argv), cfg = loadConfig();
36
+ const dry = a["dry-run"] !== undefined, force = a.force !== undefined;
37
+ const p = (a.platform || "android").toLowerCase();
38
+ if (p !== "android" && p !== "ios" && p !== "all")
39
+ throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
40
+ const platforms = p === "all" ? ["android", "ios"] : [p];
41
+ if (a.to === "true")
42
+ throw new Error("--to needs bands: pass --to b1,b2");
43
+ const explicit = a.to ? a.to.split(",").map((s) => s.trim()).filter(Boolean) : null;
44
+ if (explicit) {
45
+ const bad = explicit.filter((b) => !BANDS.includes(b));
46
+ if (bad.length)
47
+ throw new Error(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
48
+ }
49
+ const APP = cfg.appId, MAJOR = cfg.shellMajor;
50
+ const store = deps.kv ?? kvFor(cfg);
51
+ // Read every source pointer before touching the network or writing anything.
52
+ const plans = [];
53
+ for (const pf of platforms) {
54
+ let from = "", body = null;
55
+ const have = new Map();
56
+ for (const band of BANDS) {
57
+ const raw = await store.get(pointerKey(APP, MAJOR, band, pf));
58
+ if (!raw)
59
+ continue;
60
+ try {
61
+ const b = JSON.parse(JSON.parse(raw).body);
62
+ have.set(band, b.release);
63
+ if (!body) {
64
+ from = band;
65
+ body = b;
66
+ }
67
+ }
68
+ catch { /* unreadable pointer: skip */ }
69
+ }
70
+ if (!body)
71
+ throw new Error(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`);
72
+ const release = body.release;
73
+ if (explicit && explicit.length && explicit.every((b) => b === from))
74
+ throw new Error(`${from} is the source band (release ${release}). Pass --to with a different band.`);
75
+ // Never write an older release over a newer one: promote widens, it does not downgrade. --force does not change this.
76
+ const cand = (explicit ?? BANDS).filter((b) => b !== from && (explicit || have.get(b) !== release));
77
+ const newer = cand.filter((b) => (have.get(b) ?? 0) > release).map((b) => [b, have.get(b)]);
78
+ const targets = cand.filter((b) => !((have.get(b) ?? 0) > release));
79
+ plans.push({ pf, release, from, body, targets, newer });
80
+ }
81
+ if (!force) {
82
+ const base = cfg.controlPlane.replace(/\/$/, "");
83
+ let data;
84
+ try {
85
+ const r = await (deps.fetchFn ?? fetch)(`${base}/t/${encodeURIComponent(APP)}/summary`);
86
+ if (!r.ok)
87
+ throw new Error(`HTTP ${r.status}`);
88
+ data = (await r.json());
89
+ }
90
+ catch (e) {
91
+ throw new Error(`Could not read the release status from ${base} (${e instanceof Error ? e.message : String(e)}). Try again, or pass --force to promote without the check.`);
92
+ }
93
+ for (const { release } of plans) {
94
+ if (data.halted && typeof data.halted === "object" && data.halted.release === release)
95
+ throw new Error(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`);
96
+ const ev = data.releases?.find((r) => r.release === release)?.events ?? {};
97
+ const n = REVERT_EVENTS.reduce((s, k) => s + (ev[k] ?? 0), 0);
98
+ if (n > 0)
99
+ throw new Error(`Release ${release} has ${n} reported revert${n === 1 ? "" : "s"}. Check shipvo status, publish a fix, or pass --force to promote it anyway.`);
100
+ }
101
+ }
102
+ for (const { pf, release, from, body, targets, newer } of plans) {
103
+ for (const [b, r] of newer)
104
+ console.log(`Skipped ${b} (${pf}): it already has release ${r}, which is newer than ${release}.`);
105
+ if (!targets.length && newer.length) {
106
+ console.log(`Nothing to promote: every other band already has a newer release than ${release} (${pf}).`);
107
+ continue;
108
+ }
109
+ if (!targets.length) {
110
+ console.log(`Nothing to promote: release ${release} (${pf}) already has a pointer on every band.`);
111
+ continue;
112
+ }
113
+ const issued_at = new Date().toISOString();
114
+ for (const band of targets) {
115
+ if (dry) {
116
+ console.log(`Would write ${band} (${pf}): release ${release}, copied from ${from}`);
117
+ continue;
118
+ }
119
+ const text = JSON.stringify({ ...body, band, issued_at });
120
+ await store.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body: text, signature: signBytes(new TextEncoder().encode(text), seed()) }), "application/json");
121
+ console.log(`Wrote ${band} (${pf}): release ${release}, copied from ${from}`);
122
+ }
123
+ }
124
+ if (dry)
125
+ console.log("Dry run: nothing was written.");
126
+ }
package/dist/publish.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export true Reuse the last build in dist-ota/\n --skip-web true Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
1
+ export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
2
2
  export declare function publish(argv: string[]): Promise<void>;
3
3
  /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
4
4
  export declare function formatSize(bytes: number): string;