@mirinjs/cli 0.1.0-alpha.1 → 0.1.0-alpha.11

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,334 @@
1
+ /**
2
+ * Linux distributable packages for a mirin app — **AppImage**, **.deb**, **.rpm** —
3
+ * built from the assembled Linux app folder (buildLinuxBundle's output). This is the
4
+ * Linux analog of dmg.ts (macOS) and installer-inno.ts / installer-win.ts (Windows):
5
+ * `mirin build` (with packaging requested) and `mirin release` call it after the app
6
+ * folder is assembled.
7
+ *
8
+ * The app folder is a flat, relocatable payload — the host binary, libcef.so + the
9
+ * CEF runtime, and `resources/`, all resolved at runtime from an `$ORIGIN` rpath and
10
+ * `dirname(process.execPath)` (host.ts). Every package therefore ships the whole
11
+ * folder verbatim under one prefix and points a launcher at the *real* host binary
12
+ * inside it (never a bare PATH name — the host derives everything from its own dir):
13
+ *
14
+ * - deb / rpm: payload → `/opt/<id>/`, a `/usr/bin/<bin>` wrapper `exec`s
15
+ * `/opt/<id>/<App>`, plus `/usr/share/applications/<id>.desktop` and a
16
+ * `/usr/share/icons/hicolor/256x256/apps/<id>.png`.
17
+ * - AppImage: payload → `AppDir/usr/lib/<id>/`, top-level `AppRun` `exec`s it,
18
+ * `<id>.desktop` + `<id>.png` (+ `.DirIcon`) at the AppDir root, packed by
19
+ * appimagetool.
20
+ *
21
+ * Tooling (resolved via $MIRIN_FPM / $MIRIN_APPIMAGETOOL, then PATH, then a
22
+ * conventional fallback — see resolveTool):
23
+ * - **fpm** builds both deb and rpm in pure Ruby (no dpkg/rpmbuild on the host).
24
+ * - **appimagetool** packs the AppImage (invoked with `--appimage-extract-and-run`
25
+ * so it works on hosts without FUSE).
26
+ */
27
+
28
+ import { $ } from "bun";
29
+ import {
30
+ cpSync,
31
+ mkdirSync,
32
+ rmSync,
33
+ writeFileSync,
34
+ chmodSync,
35
+ existsSync,
36
+ symlinkSync,
37
+ statSync,
38
+ } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { homedir } from "node:os";
41
+ import { desktopEntry, resolveLinuxDesktopIconPng } from "./bundle-linux.ts";
42
+ import type { LinuxConfig, LinuxPackageFormat } from "mirinjs";
43
+
44
+ export interface LinuxPackageInput {
45
+ /** The assembled Linux app folder (build/<App>): the whole relocatable payload. */
46
+ appDir: string;
47
+ /** App display name; also the host-binary stem inside `appDir`. */
48
+ appName: string;
49
+ /** Reverse-DNS app id, e.g. "dev.netko.anko" (install prefix + `.desktop`/icon name). */
50
+ bundleId: string;
51
+ version: string;
52
+ /** Publisher / company (deb/rpm vendor + maintainer fallback). */
53
+ publisher: string;
54
+ /** Directory the artifacts are written into. */
55
+ outDir: string;
56
+ projectDir: string;
57
+ /** Abs path to the app icon source (`.png` / `.iconset`); resolved to a PNG. */
58
+ icon?: string;
59
+ /** Resolved Linux config (maintainer, depends, category, …). */
60
+ options: LinuxConfig;
61
+ /** Which formats to build. */
62
+ formats: LinuxPackageFormat[];
63
+ }
64
+
65
+ export interface LinuxPackageResult {
66
+ format: LinuxPackageFormat;
67
+ path: string;
68
+ size: number;
69
+ }
70
+
71
+ /** CEF/GTK runtime deps on Debian/Ubuntu package naming (best-effort; overridable). */
72
+ const DEFAULT_DEB_DEPENDS = [
73
+ "libgtk-3-0", "libnss3", "libnspr4", "libasound2", "libx11-6", "libxcomposite1",
74
+ "libxdamage1", "libxext6", "libxfixes3", "libxrandr2", "libgbm1", "libxkbcommon0",
75
+ "libpango-1.0-0", "libcairo2", "libatk1.0-0", "libatk-bridge2.0-0", "libcups2",
76
+ "libdrm2", "libdbus-1-3",
77
+ ];
78
+
79
+ /** CEF/GTK runtime deps on RPM (Fedora/RHEL/openSUSE) package naming. */
80
+ const DEFAULT_RPM_DEPENDS = [
81
+ "gtk3", "nss", "nspr", "alsa-lib", "libX11", "libXcomposite", "libXdamage",
82
+ "libXext", "libXfixes", "libXrandr", "mesa-libgbm", "libxkbcommon", "pango",
83
+ "cairo", "atk", "at-spi2-atk", "cups-libs", "libdrm", "dbus-libs",
84
+ ];
85
+
86
+ /**
87
+ * Resolve an external tool path: an explicit `$ENV` override (if it exists), then
88
+ * PATH, then the first existing `fallback`. Throws a clear, actionable error when
89
+ * none resolve (no user-specific absolute path is baked into committed code).
90
+ */
91
+ function resolveTool(bin: string, envVar: string, fallbacks: string[]): string {
92
+ const fromEnv = process.env[envVar];
93
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
94
+ const onPath = Bun.which(bin);
95
+ if (onPath) return onPath;
96
+ for (const f of fallbacks) if (existsSync(f)) return f;
97
+ throw new Error(
98
+ `[mirin] "${bin}" not found — install it and put it on PATH, or set $${envVar} ` +
99
+ `to its absolute path.`,
100
+ );
101
+ }
102
+
103
+ /** `~/.local/share/gem/ruby/<ver>/bin` (RubyGems user install dir), if resolvable. */
104
+ function gemUserBin(): string | undefined {
105
+ const ruby = Bun.which("ruby");
106
+ if (!ruby) return undefined;
107
+ try {
108
+ const dir = Bun.spawnSync([ruby, "-e", "print Gem.user_dir"]).stdout.toString().trim();
109
+ return dir ? join(dir, "bin") : undefined;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ function resolveFpm(): string {
116
+ const fallbacks: string[] = [];
117
+ const gemBin = gemUserBin();
118
+ if (gemBin) fallbacks.push(join(gemBin, "fpm"));
119
+ return resolveTool("fpm", "MIRIN_FPM", fallbacks);
120
+ }
121
+
122
+ function resolveAppimagetool(): string {
123
+ return resolveTool("appimagetool", "MIRIN_APPIMAGETOOL", [
124
+ join(homedir(), ".local", "bin", "appimagetool"),
125
+ ]);
126
+ }
127
+
128
+ /** A Debian/RPM-friendly package name: lowercase, alnum + `.-+`, no leading sigil. */
129
+ function shortName(input: string): string {
130
+ return (
131
+ input.toLowerCase().replace(/[^a-z0-9.\-+]/g, "-").replace(/^[.\-+]+/, "") || "app"
132
+ );
133
+ }
134
+
135
+ const size = (p: string) => statSync(p).size;
136
+
137
+ /** Build every requested Linux package; returns each artifact's path + size. */
138
+ export async function buildLinuxPackages(
139
+ input: LinuxPackageInput,
140
+ ): Promise<LinuxPackageResult[]> {
141
+ mkdirSync(input.outDir, { recursive: true });
142
+ const binName = input.options.binName ?? shortName(input.bundleId.split(".").pop() ?? input.appName);
143
+ const results: LinuxPackageResult[] = [];
144
+
145
+ // deb + rpm share one staged filesystem tree (payload under /opt, launcher,
146
+ // .desktop, icon). Build it once and reuse.
147
+ const needFsTree = input.formats.includes("deb") || input.formats.includes("rpm");
148
+ let fsRoot: string | undefined;
149
+ try {
150
+ if (needFsTree) fsRoot = stageFsTree(input, binName);
151
+ if (input.formats.includes("deb")) results.push(await buildFpm(input, binName, fsRoot!, "deb"));
152
+ if (input.formats.includes("rpm")) results.push(await buildFpm(input, binName, fsRoot!, "rpm"));
153
+ if (input.formats.includes("appimage")) results.push(await buildAppImage(input, binName));
154
+ } finally {
155
+ if (fsRoot) rmSync(fsRoot, { recursive: true, force: true });
156
+ }
157
+ return results;
158
+ }
159
+
160
+ /**
161
+ * Stage the on-disk filesystem layout deb/rpm install: the whole payload under
162
+ * `/opt/<id>/`, a `/usr/bin/<bin>` wrapper `exec`ing the real host binary, the
163
+ * `.desktop` entry, and a hicolor icon. Returns the staging root (fpm's `-C` chdir).
164
+ */
165
+ function stageFsTree(input: LinuxPackageInput, binName: string): string {
166
+ const root = join(input.outDir, `.pkgroot-${process.pid}`);
167
+ rmSync(root, { recursive: true, force: true });
168
+
169
+ // Payload → /opt/<id>/…
170
+ const prefix = join(root, "opt", input.bundleId);
171
+ mkdirSync(prefix, { recursive: true });
172
+ cpSync(input.appDir, prefix, { recursive: true });
173
+
174
+ // /usr/bin/<bin>: a wrapper that exec's the real binary so process.execPath (and
175
+ // thus the host's core/resources/helper resolution) points into /opt/<id>.
176
+ const binDir = join(root, "usr", "bin");
177
+ mkdirSync(binDir, { recursive: true });
178
+ const launcher = join(binDir, binName);
179
+ writeFileSync(launcher, `#!/bin/sh\nexec "/opt/${input.bundleId}/${input.appName}" "$@"\n`);
180
+ chmodSync(launcher, 0o755);
181
+
182
+ // /usr/share/applications/<id>.desktop — Icon= is the theme name (<id>), resolved
183
+ // from the hicolor icon below; Exec= is the /usr/bin launcher on PATH.
184
+ const appsDir = join(root, "usr", "share", "applications");
185
+ mkdirSync(appsDir, { recursive: true });
186
+ writeFileSync(join(appsDir, `${input.bundleId}.desktop`), buildDesktop(input, binName));
187
+
188
+ stageHicolorIcon(root, input);
189
+ return root;
190
+ }
191
+
192
+ /** Install the app icon at `usr/share/icons/hicolor/256x256/apps/<id>.png` under `root`. */
193
+ function stageHicolorIcon(root: string, input: LinuxPackageInput): void {
194
+ const png = input.icon ? resolveLinuxDesktopIconPng(input.icon) : undefined;
195
+ if (!png) return;
196
+ const dir = join(root, "usr", "share", "icons", "hicolor", "256x256", "apps");
197
+ mkdirSync(dir, { recursive: true });
198
+ cpSync(png, join(dir, `${input.bundleId}.png`));
199
+ }
200
+
201
+ /** The `.desktop` body shared by deb/rpm/AppImage (Exec = launcher name, Icon = <id>). */
202
+ function buildDesktop(input: LinuxPackageInput, binName: string): string {
203
+ return desktopEntry({
204
+ name: input.appName,
205
+ exec: binName,
206
+ iconPng: input.bundleId,
207
+ wmClass: input.bundleId,
208
+ categories: input.options.category ?? "Utility",
209
+ comment: input.options.description,
210
+ });
211
+ }
212
+
213
+ /** Build a .deb or .rpm from the staged tree via fpm. */
214
+ async function buildFpm(
215
+ input: LinuxPackageInput,
216
+ binName: string,
217
+ fsRoot: string,
218
+ kind: "deb" | "rpm",
219
+ ): Promise<LinuxPackageResult> {
220
+ const fpm = resolveFpm();
221
+ const arch = kind === "deb" ? "amd64" : "x86_64";
222
+ const fileName =
223
+ kind === "deb"
224
+ ? `${binName}_${input.version}_${arch}.deb`
225
+ : `${binName}-${input.version}-1.${arch}.rpm`;
226
+ const out = join(input.outDir, fileName);
227
+ rmSync(out, { force: true });
228
+
229
+ const depends =
230
+ kind === "deb"
231
+ ? input.options.debDepends ?? DEFAULT_DEB_DEPENDS
232
+ : input.options.rpmDepends ?? DEFAULT_RPM_DEPENDS;
233
+
234
+ const args: string[] = [
235
+ "-s", "dir",
236
+ "-t", kind,
237
+ "-C", fsRoot,
238
+ "--package", out,
239
+ "--name", binName,
240
+ "--version", input.version,
241
+ "--iteration", "1",
242
+ "--architecture", arch,
243
+ "--maintainer", input.options.maintainer ?? input.publisher,
244
+ "--vendor", input.publisher,
245
+ "--description", input.options.description ?? input.appName,
246
+ ];
247
+ if (input.options.homepage) args.push("--url", input.options.homepage);
248
+ if (input.options.license) args.push("--license", input.options.license);
249
+ for (const d of depends) args.push("--depends", d);
250
+ // Package the whole staged tree ('.' relative to the -C chdir).
251
+ args.push(".");
252
+
253
+ console.log(`[mirin] building ${kind} → ${fileName}`);
254
+ const res = await $`${fpm} ${args}`.nothrow().quiet();
255
+ if (res.exitCode !== 0 || !existsSync(out)) {
256
+ throw new Error(`fpm (${kind}) failed (exit ${res.exitCode}):\n${res.stderr.toString()}`);
257
+ }
258
+ return { format: kind, path: out, size: size(out) };
259
+ }
260
+
261
+ /** Build an AppDir and pack it into a single-file AppImage via appimagetool. */
262
+ async function buildAppImage(
263
+ input: LinuxPackageInput,
264
+ binName: string,
265
+ ): Promise<LinuxPackageResult> {
266
+ const appimagetool = resolveAppimagetool();
267
+ const appDirRoot = join(input.outDir, `.AppDir-${process.pid}`);
268
+ rmSync(appDirRoot, { recursive: true, force: true });
269
+
270
+ // Payload → AppDir/usr/lib/<id>/ so $ORIGIN resolves within it.
271
+ const payload = join(appDirRoot, "usr", "lib", input.bundleId);
272
+ mkdirSync(payload, { recursive: true });
273
+ cpSync(input.appDir, payload, { recursive: true });
274
+
275
+ // AppRun: exec the real host binary (resolves core/resources/helper from its dir).
276
+ const appRun = join(appDirRoot, "AppRun");
277
+ writeFileSync(
278
+ appRun,
279
+ [
280
+ "#!/bin/sh",
281
+ 'HERE="$(dirname "$(readlink -f "$0")")"',
282
+ `exec "$HERE/usr/lib/${input.bundleId}/${input.appName}" "$@"`,
283
+ "",
284
+ ].join("\n"),
285
+ );
286
+ chmodSync(appRun, 0o755);
287
+
288
+ // .desktop at the AppDir root (appimagetool reads it) + the standard location.
289
+ const desktop = buildDesktop(input, binName);
290
+ writeFileSync(join(appDirRoot, `${input.bundleId}.desktop`), desktop);
291
+ const appsDir = join(appDirRoot, "usr", "share", "applications");
292
+ mkdirSync(appsDir, { recursive: true });
293
+ writeFileSync(join(appsDir, `${input.bundleId}.desktop`), desktop);
294
+
295
+ // Icon: <id>.png + .DirIcon at the root, plus the hicolor copy.
296
+ const png = input.icon ? resolveLinuxDesktopIconPng(input.icon) : undefined;
297
+ if (png) {
298
+ cpSync(png, join(appDirRoot, `${input.bundleId}.png`));
299
+ try {
300
+ symlinkSync(`${input.bundleId}.png`, join(appDirRoot, ".DirIcon"));
301
+ } catch {
302
+ /* best-effort: some filesystems disallow symlinks */
303
+ }
304
+ stageHicolorIcon(appDirRoot, input);
305
+ }
306
+
307
+ const fileName = `${input.appName}-${input.version}-x86_64.AppImage`;
308
+ const out = join(input.outDir, fileName);
309
+ rmSync(out, { force: true });
310
+
311
+ console.log(`[mirin] building AppImage → ${fileName}`);
312
+ // --appimage-extract-and-run: run appimagetool without FUSE. ARCH is required by
313
+ // appimagetool; NO_STRIP avoids stripping the Bun-compiled host (which would break it).
314
+ const res = await $`${appimagetool} --appimage-extract-and-run ${appDirRoot} ${out}`
315
+ .env({ ...process.env, ARCH: "x86_64", NO_STRIP: "1" })
316
+ .nothrow()
317
+ .quiet();
318
+ rmSync(appDirRoot, { recursive: true, force: true });
319
+ if (res.exitCode !== 0 || !existsSync(out)) {
320
+ throw new Error(`appimagetool failed (exit ${res.exitCode}):\n${res.stderr.toString()}`);
321
+ }
322
+ chmodSync(out, 0o755);
323
+ return { format: "appimage", path: out, size: size(out) };
324
+ }
325
+
326
+ /** Resolve the effective set of formats from config + an optional CLI/caller override. */
327
+ export function resolveLinuxFormats(
328
+ linux: boolean | LinuxConfig | undefined,
329
+ override?: LinuxPackageFormat[],
330
+ ): LinuxPackageFormat[] {
331
+ if (override && override.length) return override;
332
+ const fromConfig = typeof linux === "object" ? linux.formats : undefined;
333
+ return fromConfig && fromConfig.length ? fromConfig : ["appimage", "deb", "rpm"];
334
+ }
package/src/release.ts CHANGED
@@ -18,11 +18,13 @@
18
18
 
19
19
  import { $ } from "bun";
20
20
  import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
21
- import { join } from "node:path";
21
+ import { join, basename } from "node:path";
22
22
  import { tmpdir } from "node:os";
23
23
  import { build } from "./build.ts";
24
24
  import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
25
25
  import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
26
+ import { buildInnoInstaller, hasInno } from "./installer-inno.ts";
27
+ import { buildLinuxPackages, resolveLinuxFormats } from "./package-linux.ts";
26
28
  import { loadCodec } from "mirinjs/codec";
27
29
 
28
30
  const sha256File = (path: string) =>
@@ -36,13 +38,14 @@ export async function release(projectDir = process.cwd()): Promise<number> {
36
38
  }
37
39
 
38
40
  const isWindows = process.platform === "win32";
39
- const platform = isWindows ? "win32" : "darwin";
41
+ const isLinux = process.platform === "linux";
42
+ const platform = isWindows ? "win32" : isLinux ? "linux" : "darwin";
40
43
  const arch = process.arch; // "arm64" | "x64"
41
44
  const prefix = `${result.channel}-${platform}-${arch}`;
42
45
  const safeName = result.appName.replace(/[^A-Za-z0-9._-]/g, "");
43
46
  const base = result.baseUrl.replace(/\/$/, "");
44
- // The packaged unit: a flat app folder on Windows, an `.app` bundle on macOS.
45
- const appArtifact = isWindows ? result.appName : `${result.appName}.app`;
47
+ // The packaged unit: a flat app folder on Windows/Linux, an `.app` bundle on macOS.
48
+ const appArtifact = isWindows || isLinux ? result.appName : `${result.appName}.app`;
46
49
 
47
50
  const buildDir = join(projectDir, "build");
48
51
  const outDir = join(buildDir, "release");
@@ -52,7 +55,7 @@ export async function release(projectDir = process.cwd()): Promise<number> {
52
55
  // Notarize + staple the .app (Developer ID, macOS) before packing, when
53
56
  // credentials are present. The .tar.zst / patch updater bundles are made from
54
57
  // this signed, stapled .app, so the updater swaps in an already-notarized app.
55
- if (!isWindows) await notarizeAndStaple(result.app);
58
+ if (!isWindows && !isLinux) await notarizeAndStaple(result.app);
56
59
 
57
60
  // Load the codec (zstd/bsdiff) from the *bundled* core, not the raw build output:
58
61
  // on Windows mirin_core.dll imports libcef.dll. The DLL loader resolves imports
@@ -130,29 +133,44 @@ export async function release(projectDir = process.cwd()): Promise<number> {
130
133
  let installerName: string | undefined;
131
134
  let installerSize = 0;
132
135
  if (isWindows) {
133
- // Preferred: a real NSIS installer (Program Files / per-user install, Start
134
- // Menu + Desktop shortcuts, uninstaller, Add/Remove Programs). Falls back to a
135
- // portable .zip when nsis is disabled or makensis isn't installed.
136
- const wantsInstaller = result.nsis !== false;
137
- if (wantsInstaller && (await hasMakensis())) {
138
- installerName = `${prefix}-${safeName}-setup.exe`;
139
- const exePath = await buildNsisInstaller({
140
- appDir: result.app,
141
- appName: result.appName,
142
- exeName: `${result.appName}.exe`,
143
- version: result.version,
144
- bundleId: result.bundleId,
145
- outDir,
146
- fileName: installerName,
147
- options: typeof result.nsis === "object" ? result.nsis : {},
148
- projectDir: result.projectDir,
136
+ // A real installer (Program Files / per-user install, Start Menu + Desktop
137
+ // shortcuts, uninstaller, Add/Remove Programs): Inno Setup (modern wizard)
138
+ // preferred, then NSIS, then a portable .zip if neither toolchain is present.
139
+ const setupName = `${prefix}-${safeName}-setup.exe`;
140
+ const installerArgs = {
141
+ appDir: result.app,
142
+ appName: result.appName,
143
+ exeName: `${result.appName}.exe`,
144
+ version: result.version,
145
+ bundleId: result.bundleId,
146
+ outDir,
147
+ fileName: setupName,
148
+ projectDir: result.projectDir,
149
+ };
150
+ const innoWanted = result.inno !== false;
151
+ const nsisWanted = result.nsis !== false;
152
+ const innoOpts = typeof result.inno === "object" ? result.inno : {};
153
+ const nsisOpts = typeof result.nsis === "object" ? result.nsis : {};
154
+ let exePath: string | undefined;
155
+ if (innoWanted && hasInno()) {
156
+ exePath = await buildInnoInstaller({
157
+ ...installerArgs,
158
+ options: { ...innoOpts, publisher: innoOpts.publisher ?? result.publisher },
149
159
  });
160
+ } else if (nsisWanted && (await hasMakensis())) {
161
+ exePath = await buildNsisInstaller({
162
+ ...installerArgs,
163
+ options: { ...nsisOpts, publisher: nsisOpts.publisher ?? result.publisher },
164
+ });
165
+ }
166
+ if (exePath) {
167
+ installerName = setupName;
150
168
  installerSize = readFileSync(exePath).byteLength;
151
169
  } else {
152
- if (wantsInstaller) {
170
+ if (innoWanted || nsisWanted) {
153
171
  console.warn(
154
- "[mirin release] makensis (NSIS) not found — shipping the portable .zip. " +
155
- "Install NSIS (e.g. `scoop install nsis`) for an installer.",
172
+ "[mirin release] no installer toolchain found — shipping the portable .zip. " +
173
+ "Install Inno Setup (`scoop install inno-setup`) or NSIS (`scoop install nsis`).",
156
174
  );
157
175
  }
158
176
  installerName = `${prefix}-${safeName}.zip`;
@@ -163,21 +181,69 @@ export async function release(projectDir = process.cwd()): Promise<number> {
163
181
  await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
164
182
  installerSize = readFileSync(zipPath).byteLength;
165
183
  }
184
+ } else if (isLinux) {
185
+ // Linux: the first-install convenience is a set of distributable packages
186
+ // (AppImage + .deb + .rpm), the analog of the macOS .dmg / Windows installer.
187
+ // Never fail the whole release over a packaging step — warn and ship the rest.
188
+ if (result.linux !== false) {
189
+ try {
190
+ const formats = resolveLinuxFormats(result.linux);
191
+ console.log(`[mirin release] building Linux packages (${formats.join(", ")})…`);
192
+ const pkgs = await buildLinuxPackages({
193
+ appDir: result.app,
194
+ appName: result.appName,
195
+ bundleId: result.bundleId,
196
+ version: result.version,
197
+ publisher: result.publisher,
198
+ outDir,
199
+ projectDir: result.projectDir,
200
+ icon: result.icon,
201
+ options: typeof result.linux === "object" ? result.linux : {},
202
+ formats,
203
+ });
204
+ // The AppImage doubles as the primary "installer" line in the summary.
205
+ const appImage = pkgs.find((p) => p.format === "appimage") ?? pkgs[0];
206
+ if (appImage) {
207
+ installerName = basename(appImage.path);
208
+ installerSize = appImage.size;
209
+ }
210
+ for (const p of pkgs) {
211
+ if (p !== appImage) console.log(` ${basename(p.path)} (${(p.size / 1e6).toFixed(1)} MB)`);
212
+ }
213
+ } catch (e) {
214
+ console.warn(
215
+ `\n[mirin release] ⚠️ Linux packaging failed: ${e instanceof Error ? e.message : e}\n` +
216
+ `[mirin release] shipping the updater bundle + manifest WITHOUT packages.\n`,
217
+ );
218
+ }
219
+ }
166
220
  } else if (result.dmg !== false) {
167
- installerName = `${prefix}-${safeName}.dmg`;
168
- console.log(`[mirin release] building installer → ${installerName}`);
169
- const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
170
- const dmgPath = await buildDmg({
171
- app: result.app,
172
- appName: result.appName,
173
- outDir,
174
- fileName: installerName,
175
- options,
176
- projectDir: result.projectDir,
177
- signIdentity: result.signIdentity,
178
- });
179
- await notarizeAndStaple(dmgPath); // no-op without notary credentials
180
- installerSize = readFileSync(dmgPath).byteLength;
221
+ // The DMG is the first-install convenience; the updater bundle + manifest (the
222
+ // critical artifacts) are already built. Never fail the whole release over a
223
+ // flaky installer step — warn loudly and ship the rest.
224
+ const dmgName = `${prefix}-${safeName}.dmg`;
225
+ console.log(`[mirin release] building installer → ${dmgName}`);
226
+ try {
227
+ const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
228
+ const dmgPath = await buildDmg({
229
+ app: result.app,
230
+ appName: result.appName,
231
+ outDir,
232
+ fileName: dmgName,
233
+ options,
234
+ projectDir: result.projectDir,
235
+ signIdentity: result.signIdentity,
236
+ });
237
+ await notarizeAndStaple(dmgPath); // no-op without notary credentials
238
+ installerName = dmgName;
239
+ installerSize = readFileSync(dmgPath).byteLength;
240
+ } catch (e) {
241
+ console.warn(
242
+ `\n[mirin release] ⚠️ DMG build failed: ${e instanceof Error ? e.message : e}\n` +
243
+ `[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n`,
244
+ );
245
+ rmSync(join(outDir, dmgName), { force: true });
246
+ }
181
247
  }
182
248
 
183
249
  const mb = (n: number) => (n / 1e6).toFixed(1);