@mirinjs/cli 0.1.0-alpha.14 → 0.1.0-alpha.16

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,105 @@
1
+ export interface PreviousReleaseManifest {
2
+ version: string;
3
+ bundle: {
4
+ url: string;
5
+ sha256: string;
6
+ size: number;
7
+ };
8
+ }
9
+
10
+ const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024 * 1024;
11
+
12
+ export function parsePreviousReleaseManifest(
13
+ value: unknown,
14
+ expected: { channel: string; platform: string; arch: string },
15
+ ): PreviousReleaseManifest {
16
+ const manifest = record(value, "manifest");
17
+ for (const key of ["channel", "platform", "arch"] as const) {
18
+ if (stringField(manifest, key) !== expected[key]) {
19
+ throw new Error(`previous update manifest target mismatch: ${key}`);
20
+ }
21
+ }
22
+
23
+ const version = stringField(manifest, "version");
24
+ if (
25
+ version.length > 128 ||
26
+ !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
27
+ version,
28
+ )
29
+ ) {
30
+ throw new Error("invalid previous update version");
31
+ }
32
+
33
+ const bundle = record(manifest.bundle, "bundle");
34
+ const url = stringField(bundle, "url");
35
+ releaseArtifactUrl("https://example.invalid", url);
36
+ const sha256 = stringField(bundle, "sha256").toLowerCase();
37
+ if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error("invalid previous bundle checksum");
38
+ const size = bundle.size;
39
+ if (
40
+ typeof size !== "number" ||
41
+ !Number.isSafeInteger(size) ||
42
+ size <= 0 ||
43
+ size > MAX_ARTIFACT_BYTES
44
+ ) {
45
+ throw new Error("invalid previous bundle size");
46
+ }
47
+
48
+ return { version, bundle: { url, sha256, size } };
49
+ }
50
+
51
+ export function trustedReleaseBaseUrl(raw: string): string {
52
+ let url: URL;
53
+ try {
54
+ url = new URL(raw);
55
+ } catch {
56
+ throw new Error(`invalid release baseUrl: ${raw}`);
57
+ }
58
+ assertTrustedReleaseUrl(url.toString());
59
+ url.hash = "";
60
+ url.search = "";
61
+ return url.toString().replace(/\/$/, "");
62
+ }
63
+
64
+ export function assertTrustedReleaseUrl(raw: string): void {
65
+ const url = new URL(raw);
66
+ if (url.protocol === "https:") return;
67
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return;
68
+ throw new Error("release URLs must use HTTPS, except loopback HTTP for local testing");
69
+ }
70
+
71
+ export function releaseArtifactUrl(base: string, fileName: string): string {
72
+ if (
73
+ fileName.length === 0 ||
74
+ fileName.length > 255 ||
75
+ fileName.includes("/") ||
76
+ fileName.includes("\\") ||
77
+ fileName.includes("?") ||
78
+ fileName.includes("#") ||
79
+ fileName === "." ||
80
+ fileName === ".."
81
+ ) {
82
+ throw new Error(`unsafe previous update artifact name: ${fileName}`);
83
+ }
84
+ return `${base}/${fileName}`;
85
+ }
86
+
87
+ function record(value: unknown, label: string): Record<string, unknown> {
88
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
89
+ throw new Error(`invalid previous update ${label}`);
90
+ }
91
+ return value as Record<string, unknown>;
92
+ }
93
+
94
+ function stringField(source: Record<string, unknown>, key: string): string {
95
+ const value = source[key];
96
+ if (typeof value !== "string" || value.length === 0) {
97
+ throw new Error(`invalid previous update field: ${key}`);
98
+ }
99
+ return value;
100
+ }
101
+
102
+ function isLoopbackHost(hostname: string): boolean {
103
+ const host = hostname.toLowerCase();
104
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
105
+ }
package/src/release.ts CHANGED
@@ -16,19 +16,32 @@
16
16
  * any static host. Channels coexist because the channel is part of every name.
17
17
  */
18
18
 
19
- import { $ } from "bun";
20
- import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
21
- import { join, basename } from "node:path";
19
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
22
20
  import { tmpdir } from "node:os";
23
- import { build } from "./build.ts";
24
- import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
25
- import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
26
- import { buildInnoInstaller, hasInno } from "./installer-inno.ts";
27
- import { buildLinuxPackages, resolveLinuxFormats } from "./package-linux.ts";
21
+ import { join } from "node:path";
22
+ import { $ } from "bun";
28
23
  import { loadCodec } from "mirinjs/codec";
24
+ import { build } from "./build.ts";
25
+ import { notarizeAndStaple } from "./dmg.ts";
26
+ import { buildReleaseInstaller } from "./release/installers.ts";
27
+ import {
28
+ assertTrustedReleaseUrl,
29
+ parsePreviousReleaseManifest,
30
+ releaseArtifactUrl,
31
+ trustedReleaseBaseUrl,
32
+ } from "./release/previous.ts";
29
33
 
30
- const sha256File = (path: string) =>
31
- new Bun.CryptoHasher("sha256").update(readFileSync(path)).digest("hex");
34
+ // Level 10 keeps updater bundles compact without level 19's steep CPU cost.
35
+ // The native codec uses multiple workers, so this scales across CI runner cores.
36
+ const RELEASE_COMPRESSION_LEVEL = 10;
37
+
38
+ async function sha256File(path: string): Promise<string> {
39
+ const hasher = new Bun.CryptoHasher("sha256");
40
+ for await (const chunk of Bun.file(path).stream()) {
41
+ hasher.update(chunk);
42
+ }
43
+ return hasher.digest("hex");
44
+ }
32
45
 
33
46
  export async function release(projectDir = process.cwd()): Promise<number> {
34
47
  const result = await build(projectDir);
@@ -43,7 +56,7 @@ export async function release(projectDir = process.cwd()): Promise<number> {
43
56
  const arch = process.arch; // "arm64" | "x64"
44
57
  const prefix = `${result.channel}-${platform}-${arch}`;
45
58
  const safeName = result.appName.replace(/[^A-Za-z0-9._-]/g, "");
46
- const base = result.baseUrl.replace(/\/$/, "");
59
+ const base = trustedReleaseBaseUrl(result.baseUrl);
47
60
  // The packaged unit: a flat app folder on Windows/Linux, an `.app` bundle on macOS.
48
61
  const appArtifact = isWindows || isLinux ? result.appName : `${result.appName}.app`;
49
62
 
@@ -57,6 +70,19 @@ export async function release(projectDir = process.cwd()): Promise<number> {
57
70
  // this signed, stapled .app, so the updater swaps in an already-notarized app.
58
71
  if (!isWindows && !isLinux) await notarizeAndStaple(result.app);
59
72
 
73
+ // Installer/package tooling runs in child processes and reads the assembled app.
74
+ // Start it now so it overlaps updater compression and delta generation below.
75
+ const installerBuild = buildReleaseInstaller({
76
+ result,
77
+ buildDir,
78
+ outDir,
79
+ appArtifact,
80
+ prefix,
81
+ safeName,
82
+ isWindows,
83
+ isLinux,
84
+ });
85
+
60
86
  // Load the codec (zstd/bsdiff) from the *bundled* core, not the raw build output:
61
87
  // on Windows mirin_core.dll imports libcef.dll. The DLL loader resolves imports
62
88
  // from PATH (and the loader exe's dir), not the dll's own dir, so prepend the
@@ -75,44 +101,58 @@ export async function release(projectDir = process.cwd()): Promise<number> {
75
101
  // Uncompressed tar — the identity + diff/patch basis (BSD tar keeps symlinks).
76
102
  const newTar = join(outDir, "_new.tar");
77
103
  await $`tar -cf ${newTar} -C ${buildDir} ${appArtifact}`;
78
- const tarHash = sha256File(newTar);
104
+ const tarHash = await sha256File(newTar);
79
105
 
80
106
  // Full bundle: zstd(newTar).
81
107
  const bundleName = `${prefix}-${safeName}${isWindows ? "" : ".app"}.tar.zst`;
82
108
  const bundlePath = join(outDir, bundleName);
83
109
  console.log(`[mirin release] compressing → ${bundleName}`);
84
- codec.compress(newTar, bundlePath, 19);
85
- const bundleSha = sha256File(bundlePath);
86
- const bundleSize = readFileSync(bundlePath).byteLength;
110
+ codec.compress(newTar, bundlePath, RELEASE_COMPRESSION_LEVEL);
111
+ const bundleSha = await sha256File(bundlePath);
112
+ const bundleSize = statSync(bundlePath).size;
87
113
 
88
114
  // Delta patch vs the previous release (if reachable). Best-effort.
89
115
  const patches: Array<{ fromVersion: string; url: string; sha256: string; size: number }> = [];
90
116
  try {
91
- const prevRes = await fetch(`${base}/${prefix}-update.json?t=${Date.now()}`, { redirect: "follow" });
117
+ const manifestUrl = new URL(releaseArtifactUrl(base, `${prefix}-update.json`));
118
+ manifestUrl.searchParams.set("t", String(Date.now()));
119
+ const prevRes = await fetch(manifestUrl, {
120
+ redirect: "follow",
121
+ });
122
+ assertTrustedReleaseUrl(prevRes.url);
92
123
  if (prevRes.ok) {
93
- const prev = (await prevRes.json()) as { version: string; bundle?: { url: string } };
94
- if (prev.version && prev.version !== result.version && prev.bundle?.url) {
124
+ const prev = parsePreviousReleaseManifest(await prevRes.json(), {
125
+ channel: result.channel,
126
+ platform,
127
+ arch,
128
+ });
129
+ if (prev.version !== result.version) {
95
130
  console.log(`[mirin release] generating delta ${prev.version} → ${result.version}…`);
96
- const tmp = join(tmpdir(), `mirin-release-${Date.now()}`);
97
- mkdirSync(tmp, { recursive: true });
98
- const prevZst = join(tmp, "prev.tar.zst");
99
- const dl = await fetch(`${base}/${prev.bundle.url}`, { redirect: "follow" });
100
- if (!dl.ok || !dl.body) throw new Error(`prev bundle ${dl.status}`);
101
- await Bun.write(prevZst, await dl.arrayBuffer());
102
- const prevTar = join(tmp, "prev.tar");
103
- codec.decompress(prevZst, prevTar);
104
- const rawPatch = join(tmp, "patch.bin");
105
- codec.diff(prevTar, newTar, rawPatch); // bsdiff
106
- const patchName = `${prefix}-${prev.version}.patch`;
107
- const patchPath = join(outDir, patchName);
108
- codec.compress(rawPatch, patchPath, 19);
109
- rmSync(tmp, { recursive: true, force: true });
110
- patches.push({
111
- fromVersion: prev.version,
112
- url: patchName,
113
- sha256: sha256File(patchPath),
114
- size: readFileSync(patchPath).byteLength,
115
- });
131
+ const tmp = mkdtempSync(join(tmpdir(), "mirin-release-"));
132
+ try {
133
+ const prevZst = join(tmp, "prev.tar.zst");
134
+ await downloadVerified(
135
+ releaseArtifactUrl(base, prev.bundle.url),
136
+ prevZst,
137
+ prev.bundle.sha256,
138
+ prev.bundle.size,
139
+ );
140
+ const prevTar = join(tmp, "prev.tar");
141
+ codec.decompress(prevZst, prevTar);
142
+ const rawPatch = join(tmp, "patch.bin");
143
+ codec.diff(prevTar, newTar, rawPatch); // bsdiff
144
+ const patchName = `${prefix}-${prev.version}.patch`;
145
+ const patchPath = join(outDir, patchName);
146
+ codec.compress(rawPatch, patchPath, RELEASE_COMPRESSION_LEVEL);
147
+ patches.push({
148
+ fromVersion: prev.version,
149
+ url: patchName,
150
+ sha256: await sha256File(patchPath),
151
+ size: statSync(patchPath).size,
152
+ });
153
+ } finally {
154
+ rmSync(tmp, { recursive: true, force: true });
155
+ }
116
156
  }
117
157
  }
118
158
  } catch (e) {
@@ -126,6 +166,7 @@ export async function release(projectDir = process.cwd()): Promise<number> {
126
166
  channel: result.channel,
127
167
  platform,
128
168
  arch,
169
+ body: result.releaseNotes,
129
170
  tarHash,
130
171
  bundle: { url: bundleName, sha256: bundleSha, size: bundleSize },
131
172
  patches,
@@ -133,127 +174,10 @@ export async function release(projectDir = process.cwd()): Promise<number> {
133
174
  const manifestName = `${prefix}-update.json`;
134
175
  await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
135
176
 
136
- // Distributable installer for first-time installs (the updater uses the
137
- // .tar.zst/patch): a drag-to-Applications .dmg on macOS, a .zip of the app
138
- // folder on Windows.
139
- let installerName: string | undefined;
140
- let installerSize = 0;
141
- if (isWindows) {
142
- // A real installer (Program Files / per-user install, Start Menu + Desktop
143
- // shortcuts, uninstaller, Add/Remove Programs): Inno Setup (modern wizard)
144
- // preferred, then NSIS, then a portable .zip if neither toolchain is present.
145
- const setupName = `${prefix}-${safeName}-setup.exe`;
146
- const installerArgs = {
147
- appDir: result.app,
148
- appName: result.appName,
149
- exeName: `${result.appName}.exe`,
150
- version: result.version,
151
- bundleId: result.bundleId,
152
- outDir,
153
- fileName: setupName,
154
- projectDir: result.projectDir,
155
- };
156
- const innoWanted = result.inno !== false;
157
- const nsisWanted = result.nsis !== false;
158
- const innoOpts = typeof result.inno === "object" ? result.inno : {};
159
- const nsisOpts = typeof result.nsis === "object" ? result.nsis : {};
160
- let exePath: string | undefined;
161
- if (innoWanted && hasInno()) {
162
- exePath = await buildInnoInstaller({
163
- ...installerArgs,
164
- options: { ...innoOpts, publisher: innoOpts.publisher ?? result.publisher },
165
- });
166
- } else if (nsisWanted && (await hasMakensis())) {
167
- exePath = await buildNsisInstaller({
168
- ...installerArgs,
169
- options: { ...nsisOpts, publisher: nsisOpts.publisher ?? result.publisher },
170
- });
171
- }
172
- if (exePath) {
173
- installerName = setupName;
174
- installerSize = readFileSync(exePath).byteLength;
175
- } else {
176
- if (innoWanted || nsisWanted) {
177
- console.warn(
178
- "[mirin release] no installer toolchain found — shipping the portable .zip. " +
179
- "Install Inno Setup (`scoop install inno-setup`) or NSIS (`scoop install nsis`).",
180
- );
181
- }
182
- installerName = `${prefix}-${safeName}.zip`;
183
- console.log(`[mirin release] zipping → ${installerName}`);
184
- const zipPath = join(outDir, installerName);
185
- rmSync(zipPath, { force: true });
186
- // bsdtar (Windows `tar`) creates a .zip from the extension with `-a`.
187
- await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
188
- installerSize = readFileSync(zipPath).byteLength;
189
- }
190
- } else if (isLinux) {
191
- // Linux: the first-install convenience is a set of distributable packages
192
- // (AppImage + .deb + .rpm), the analog of the macOS .dmg / Windows installer.
193
- // Never fail the whole release over a packaging step — warn and ship the rest.
194
- if (result.linux !== false) {
195
- try {
196
- const formats = resolveLinuxFormats(result.linux);
197
- console.log(`[mirin release] building Linux packages (${formats.join(", ")})…`);
198
- const pkgs = await buildLinuxPackages({
199
- appDir: result.app,
200
- appName: result.appName,
201
- bundleId: result.bundleId,
202
- version: result.version,
203
- publisher: result.publisher,
204
- outDir,
205
- projectDir: result.projectDir,
206
- icon: result.icon,
207
- options: typeof result.linux === "object" ? result.linux : {},
208
- formats,
209
- });
210
- // The AppImage doubles as the primary "installer" line in the summary.
211
- const appImage = pkgs.find((p) => p.format === "appimage") ?? pkgs[0];
212
- if (appImage) {
213
- installerName = basename(appImage.path);
214
- installerSize = appImage.size;
215
- }
216
- for (const p of pkgs) {
217
- if (p !== appImage) console.log(` ${basename(p.path)} (${(p.size / 1e6).toFixed(1)} MB)`);
218
- }
219
- } catch (e) {
220
- console.warn(
221
- `\n[mirin release] ⚠️ Linux packaging failed: ${e instanceof Error ? e.message : e}\n` +
222
- `[mirin release] shipping the updater bundle + manifest WITHOUT packages.\n`,
223
- );
224
- }
225
- }
226
- } else if (result.dmg !== false) {
227
- // The DMG is the first-install convenience; the updater bundle + manifest (the
228
- // critical artifacts) are already built. Never fail the whole release over a
229
- // flaky installer step — warn loudly and ship the rest.
230
- const dmgName = `${prefix}-${safeName}.dmg`;
231
- console.log(`[mirin release] building installer → ${dmgName}`);
232
- try {
233
- const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
234
- const dmgPath = await buildDmg({
235
- app: result.app,
236
- appName: result.appName,
237
- outDir,
238
- fileName: dmgName,
239
- options,
240
- projectDir: result.projectDir,
241
- signIdentity: result.signIdentity,
242
- });
243
- await notarizeAndStaple(dmgPath); // no-op without notary credentials
244
- installerName = dmgName;
245
- installerSize = readFileSync(dmgPath).byteLength;
246
- } catch (e) {
247
- console.warn(
248
- `\n[mirin release] ⚠️ DMG build failed: ${e instanceof Error ? e.message : e}\n` +
249
- `[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n`,
250
- );
251
- rmSync(join(outDir, dmgName), { force: true });
252
- }
253
- }
177
+ const { installerName, installerSize } = await installerBuild;
254
178
 
255
179
  const mb = (n: number) => (n / 1e6).toFixed(1);
256
- console.log(`\n[mirin release] done → build/release/`);
180
+ console.log("\n[mirin release] done → build/release/");
257
181
  console.log(` ${manifestName}`);
258
182
  console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
259
183
  for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
@@ -262,3 +186,49 @@ export async function release(projectDir = process.cwd()): Promise<number> {
262
186
  if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
263
187
  return 0;
264
188
  }
189
+
190
+ async function downloadVerified(
191
+ url: string,
192
+ destination: string,
193
+ expectedSha256: string,
194
+ expectedSize: number,
195
+ ): Promise<void> {
196
+ const response = await fetch(url, { redirect: "follow" });
197
+ assertTrustedReleaseUrl(response.url);
198
+ if (!response.ok || !response.body) throw new Error(`previous bundle ${response.status}`);
199
+
200
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
201
+ if (contentLength > 0 && contentLength !== expectedSize) {
202
+ throw new Error(
203
+ `previous bundle size mismatch (expected ${expectedSize}, got ${contentLength})`,
204
+ );
205
+ }
206
+
207
+ const reader = response.body.getReader();
208
+ const writer = Bun.file(destination).writer();
209
+ const hasher = new Bun.CryptoHasher("sha256");
210
+ let received = 0;
211
+ try {
212
+ try {
213
+ for (;;) {
214
+ const { done, value } = await reader.read();
215
+ if (done) break;
216
+ received += value.byteLength;
217
+ if (received > expectedSize) throw new Error("previous bundle exceeds declared size");
218
+ hasher.update(value);
219
+ writer.write(value);
220
+ }
221
+ } finally {
222
+ await writer.end();
223
+ }
224
+ if (received !== expectedSize) {
225
+ throw new Error(`previous bundle size mismatch (expected ${expectedSize}, got ${received})`);
226
+ }
227
+ if (hasher.digest("hex") !== expectedSha256) {
228
+ throw new Error("previous bundle checksum mismatch");
229
+ }
230
+ } catch (error) {
231
+ rmSync(destination, { force: true });
232
+ throw error;
233
+ }
234
+ }