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

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,53 @@
1
+ import type { LinuxPackageFormat } from "../types.ts";
2
+
3
+ /** CEF/GTK runtime deps on Debian/Ubuntu package naming (best-effort; overridable). */
4
+ export const DEFAULT_DEB_DEPENDS = [
5
+ "libgtk-3-0",
6
+ "libnss3",
7
+ "libnspr4",
8
+ "libasound2",
9
+ "libx11-6",
10
+ "libxcomposite1",
11
+ "libxdamage1",
12
+ "libxext6",
13
+ "libxfixes3",
14
+ "libxrandr2",
15
+ "libgbm1",
16
+ "libxkbcommon0",
17
+ "libpango-1.0-0",
18
+ "libcairo2",
19
+ "libatk1.0-0",
20
+ "libatk-bridge2.0-0",
21
+ "libcups2",
22
+ "libdrm2",
23
+ "libdbus-1-3",
24
+ ];
25
+
26
+ /** CEF/GTK runtime deps on RPM (Fedora/RHEL/openSUSE) package naming. */
27
+ export const DEFAULT_RPM_DEPENDS = [
28
+ "gtk3",
29
+ "nss",
30
+ "nspr",
31
+ "alsa-lib",
32
+ "libX11",
33
+ "libXcomposite",
34
+ "libXdamage",
35
+ "libXext",
36
+ "libXfixes",
37
+ "libXrandr",
38
+ "mesa-libgbm",
39
+ "libxkbcommon",
40
+ "pango",
41
+ "cairo",
42
+ "atk",
43
+ "at-spi2-atk",
44
+ "cups-libs",
45
+ "libdrm",
46
+ "dbus-libs",
47
+ ];
48
+
49
+ export const LINUX_FORMATS = [
50
+ "appimage",
51
+ "deb",
52
+ "rpm",
53
+ ] as const satisfies readonly LinuxPackageFormat[];
@@ -0,0 +1,45 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Resolve an external tool path: an explicit `$ENV` override (if it exists), then
7
+ * PATH, then the first existing `fallback`. Throws a clear, actionable error when
8
+ * none resolve (no user-specific absolute path is baked into committed code).
9
+ */
10
+ function resolveTool(bin: string, envVar: string, fallbacks: string[]): string {
11
+ const fromEnv = process.env[envVar];
12
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
13
+ const onPath = Bun.which(bin);
14
+ if (onPath) return onPath;
15
+ for (const f of fallbacks) if (existsSync(f)) return f;
16
+ throw new Error(
17
+ `[mirin] "${bin}" not found — install it and put it on PATH, or set $${envVar} ` +
18
+ "to its absolute path.",
19
+ );
20
+ }
21
+
22
+ /** `~/.local/share/gem/ruby/<ver>/bin` (RubyGems user install dir), if resolvable. */
23
+ function gemUserBin(): string | undefined {
24
+ const ruby = Bun.which("ruby");
25
+ if (!ruby) return undefined;
26
+ try {
27
+ const dir = Bun.spawnSync([ruby, "-e", "print Gem.user_dir"]).stdout.toString().trim();
28
+ return dir ? join(dir, "bin") : undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ export function resolveFpm(): string {
35
+ const fallbacks: string[] = [];
36
+ const gemBin = gemUserBin();
37
+ if (gemBin) fallbacks.push(join(gemBin, "fpm"));
38
+ return resolveTool("fpm", "MIRIN_FPM", fallbacks);
39
+ }
40
+
41
+ export function resolveAppimagetool(): string {
42
+ return resolveTool("appimagetool", "MIRIN_APPIMAGETOOL", [
43
+ join(homedir(), ".local", "bin", "appimagetool"),
44
+ ]);
45
+ }
@@ -0,0 +1,71 @@
1
+ import type { LinuxPackageFormat, LinuxPackageInput } from "../types.ts";
2
+ import { LINUX_FORMATS } from "./defaults.ts";
3
+
4
+ /** A Debian/RPM-friendly package name: lowercase, alnum + `.-+`, no leading sigil. */
5
+ function shortName(input: string): string {
6
+ return (
7
+ input
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9.\-+]/g, "-")
10
+ .replace(/^[.\-+]+/, "") || "app"
11
+ );
12
+ }
13
+
14
+ export function safeLinuxPackageInput(input: LinuxPackageInput): LinuxPackageInput {
15
+ return {
16
+ ...input,
17
+ appName: safeAppBinaryName(input.appName),
18
+ bundleId: safeLinuxIdentifier(input.bundleId),
19
+ version: safePackageVersion(input.version),
20
+ };
21
+ }
22
+
23
+ function safeLinuxIdentifier(value: string): string {
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) || value === "." || value === "..") {
25
+ throw new Error(
26
+ `[mirin] invalid Linux app id "${value}" — use a reverse-DNS style id with letters, ` +
27
+ 'digits, ".", "_" or "-".',
28
+ );
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function safeAppBinaryName(value: string): string {
34
+ if (value.length === 0 || value === "." || value === ".." || /[\0\r\n/"\\`$]/.test(value)) {
35
+ throw new Error(
36
+ `[mirin] invalid Linux app name "${value}" — it becomes a binary path segment, ` +
37
+ "so it cannot contain path separators, quotes, shell expansion characters, or newlines.",
38
+ );
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function safePackageVersion(value: string): string {
44
+ if (!/^[A-Za-z0-9][A-Za-z0-9.+:~_-]*$/.test(value)) {
45
+ throw new Error(
46
+ `[mirin] invalid Linux package version "${value}" — use only letters, digits, ".", "+", ":", "~", "_" or "-".`,
47
+ );
48
+ }
49
+ return value;
50
+ }
51
+
52
+ export function safePackageName(value: string): string {
53
+ return shortName(value);
54
+ }
55
+
56
+ export function validateLinuxFormats(
57
+ formats: readonly string[],
58
+ source: string,
59
+ ): LinuxPackageFormat[] {
60
+ const allowed = new Set<string>(LINUX_FORMATS);
61
+ const out: LinuxPackageFormat[] = [];
62
+ for (const format of formats) {
63
+ if (!allowed.has(format)) {
64
+ throw new Error(
65
+ `[mirin] invalid Linux package format "${format}" in ${source}; expected appimage, deb, or rpm.`,
66
+ );
67
+ }
68
+ if (!out.includes(format as LinuxPackageFormat)) out.push(format as LinuxPackageFormat);
69
+ }
70
+ return out;
71
+ }
@@ -25,136 +25,55 @@
25
25
  * so it works on hosts without FUSE).
26
26
  */
27
27
 
28
- import { $ } from "bun";
29
28
  import {
29
+ chmodSync,
30
30
  cpSync,
31
+ existsSync,
31
32
  mkdirSync,
32
33
  rmSync,
33
- writeFileSync,
34
- chmodSync,
35
- existsSync,
36
- symlinkSync,
37
34
  statSync,
35
+ symlinkSync,
36
+ writeFileSync,
38
37
  } from "node:fs";
39
38
  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
- }
39
+ import { $ } from "bun";
40
+ import { desktopEntry, resolveLinuxDesktopIconPng } from "../../bundle/linux/index.ts";
41
+ import { DEFAULT_DEB_DEPENDS, DEFAULT_RPM_DEPENDS, LINUX_FORMATS } from "./lib/defaults.ts";
42
+ import { resolveAppimagetool, resolveFpm } from "./lib/tools.ts";
43
+ import { safeLinuxPackageInput, safePackageName, validateLinuxFormats } from "./lib/validation.ts";
44
+ import type {
45
+ LinuxConfig,
46
+ LinuxPackageFormat,
47
+ LinuxPackageInput,
48
+ LinuxPackageResult,
49
+ } from "./types.ts";
134
50
 
135
51
  const size = (p: string) => statSync(p).size;
136
52
 
137
53
  /** 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
-
54
+ export async function buildLinuxPackages(input: LinuxPackageInput): Promise<LinuxPackageResult[]> {
55
+ const safeInput = safeLinuxPackageInput(input);
56
+ mkdirSync(safeInput.outDir, { recursive: true });
57
+ const binName = safePackageName(
58
+ safeInput.options.binName ?? safeInput.bundleId.split(".").pop() ?? safeInput.appName,
59
+ );
145
60
  // 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");
61
+ // .desktop, icon). Build it once and reuse. Package compressors only read the
62
+ // staged tree, so all requested formats can run concurrently.
63
+ const needFsTree = safeInput.formats.includes("deb") || safeInput.formats.includes("rpm");
148
64
  let fsRoot: string | undefined;
149
65
  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));
66
+ if (needFsTree) fsRoot = stageFsTree(safeInput, binName);
67
+ return await Promise.all(
68
+ safeInput.formats.map((format) =>
69
+ format === "appimage"
70
+ ? buildAppImage(safeInput, binName)
71
+ : buildFpm(safeInput, binName, fsRoot!, format),
72
+ ),
73
+ );
154
74
  } finally {
155
75
  if (fsRoot) rmSync(fsRoot, { recursive: true, force: true });
156
76
  }
157
- return results;
158
77
  }
159
78
 
160
79
  /**
@@ -228,25 +147,44 @@ async function buildFpm(
228
147
 
229
148
  const depends =
230
149
  kind === "deb"
231
- ? input.options.debDepends ?? DEFAULT_DEB_DEPENDS
232
- : input.options.rpmDepends ?? DEFAULT_RPM_DEPENDS;
150
+ ? (input.options.debDepends ?? DEFAULT_DEB_DEPENDS)
151
+ : (input.options.rpmDepends ?? DEFAULT_RPM_DEPENDS);
233
152
 
234
153
  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,
154
+ "-s",
155
+ "dir",
156
+ "-t",
157
+ kind,
158
+ "-C",
159
+ fsRoot,
160
+ "--package",
161
+ out,
162
+ "--name",
163
+ binName,
164
+ "--version",
165
+ input.version,
166
+ "--iteration",
167
+ "1",
168
+ "--architecture",
169
+ arch,
170
+ "--maintainer",
171
+ input.options.maintainer ?? input.publisher,
172
+ "--vendor",
173
+ input.publisher,
174
+ "--description",
175
+ input.options.description ?? input.appName,
246
176
  ];
247
177
  if (input.options.homepage) args.push("--url", input.options.homepage);
248
178
  if (input.options.license) args.push("--license", input.options.license);
249
179
  for (const d of depends) args.push("--depends", d);
180
+ // CEF dominates the payload. Level 3 avoids spending minutes chasing small
181
+ // gains while retaining gzip compatibility across Debian and RPM families.
182
+ args.push(
183
+ kind === "deb" ? "--deb-compression" : "--rpm-compression",
184
+ kind === "deb" ? "gz" : "gzip",
185
+ kind === "deb" ? "--deb-compression-level" : "--rpm-compression-level",
186
+ "3",
187
+ );
250
188
  // Package the whole staged tree ('.' relative to the -C chdir).
251
189
  args.push(".");
252
190
 
@@ -328,7 +266,20 @@ export function resolveLinuxFormats(
328
266
  linux: boolean | LinuxConfig | undefined,
329
267
  override?: LinuxPackageFormat[],
330
268
  ): LinuxPackageFormat[] {
331
- if (override && override.length) return override;
269
+ if (override && override.length) return validateLinuxFormats(override, "CLI override");
332
270
  const fromConfig = typeof linux === "object" ? linux.formats : undefined;
333
- return fromConfig && fromConfig.length ? fromConfig : ["appimage", "deb", "rpm"];
271
+ return fromConfig && fromConfig.length
272
+ ? validateLinuxFormats(fromConfig, "mirin.config.ts linux.formats")
273
+ : [...LINUX_FORMATS];
274
+ }
275
+
276
+ export function parseLinuxFormats(raw: string): LinuxPackageFormat[] {
277
+ const formats = raw
278
+ .split(",")
279
+ .map((s) => s.trim())
280
+ .filter(Boolean);
281
+ if (!formats.length) {
282
+ throw new Error("[mirin] --linux-target needs at least one format: appimage, deb, or rpm.");
283
+ }
284
+ return validateLinuxFormats(formats, "--linux-target");
334
285
  }
@@ -0,0 +1,30 @@
1
+ import type { LinuxConfig, LinuxPackageFormat } from "mirinjs";
2
+
3
+ export type { LinuxConfig, LinuxPackageFormat };
4
+
5
+ export interface LinuxPackageInput {
6
+ /** The assembled Linux app folder (build/<App>): the whole relocatable payload. */
7
+ appDir: string;
8
+ /** App display name; also the host-binary stem inside `appDir`. */
9
+ appName: string;
10
+ /** Reverse-DNS app id, e.g. "dev.netko.anko" (install prefix + `.desktop`/icon name). */
11
+ bundleId: string;
12
+ version: string;
13
+ /** Publisher / company (deb/rpm vendor + maintainer fallback). */
14
+ publisher: string;
15
+ /** Directory the artifacts are written into. */
16
+ outDir: string;
17
+ projectDir: string;
18
+ /** Abs path to the app icon source (`.png` / `.iconset`); resolved to a PNG. */
19
+ icon?: string;
20
+ /** Resolved Linux config (maintainer, depends, category, ...). */
21
+ options: LinuxConfig;
22
+ /** Which formats to build. */
23
+ formats: LinuxPackageFormat[];
24
+ }
25
+
26
+ export interface LinuxPackageResult {
27
+ format: LinuxPackageFormat;
28
+ path: string;
29
+ size: number;
30
+ }
@@ -0,0 +1,148 @@
1
+ import { rmSync, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { $ } from "bun";
4
+ import type { BuildResult } from "../build.ts";
5
+ import { buildDmg, type DmgOptions, notarizeAndStaple } from "../dmg.ts";
6
+ import { buildInnoInstaller, hasInno } from "../installer-inno.ts";
7
+ import { buildNsisInstaller, hasMakensis } from "../installer-win.ts";
8
+ import { buildLinuxPackages, resolveLinuxFormats } from "../package/linux/index.ts";
9
+
10
+ export interface ReleaseInstallerResult {
11
+ installerName?: string;
12
+ installerSize: number;
13
+ }
14
+
15
+ export interface ReleaseInstallerInput {
16
+ result: BuildResult;
17
+ buildDir: string;
18
+ outDir: string;
19
+ appArtifact: string;
20
+ prefix: string;
21
+ safeName: string;
22
+ isWindows: boolean;
23
+ isLinux: boolean;
24
+ }
25
+
26
+ /** Build first-install artifacts independently from updater compression and deltas. */
27
+ export async function buildReleaseInstaller(
28
+ input: ReleaseInstallerInput,
29
+ ): Promise<ReleaseInstallerResult> {
30
+ const { result, isWindows, isLinux } = input;
31
+ if (isWindows) return buildWindowsInstaller(input);
32
+ if (isLinux) return buildLinuxInstallers(input);
33
+ if (result.dmg !== false) return buildMacInstaller(input);
34
+ return { installerSize: 0 };
35
+ }
36
+
37
+ async function buildWindowsInstaller(
38
+ input: ReleaseInstallerInput,
39
+ ): Promise<ReleaseInstallerResult> {
40
+ const { result, buildDir, outDir, appArtifact, prefix, safeName } = input;
41
+ const setupName = `${prefix}-${safeName}-setup.exe`;
42
+ const installerArgs = {
43
+ appDir: result.app,
44
+ appName: result.appName,
45
+ exeName: `${result.appName}.exe`,
46
+ version: result.version,
47
+ bundleId: result.bundleId,
48
+ outDir,
49
+ fileName: setupName,
50
+ projectDir: result.projectDir,
51
+ };
52
+ const innoWanted = result.inno !== false;
53
+ const nsisWanted = result.nsis !== false;
54
+ const innoOpts = typeof result.inno === "object" ? result.inno : {};
55
+ const nsisOpts = typeof result.nsis === "object" ? result.nsis : {};
56
+ let exePath: string | undefined;
57
+
58
+ if (innoWanted && hasInno()) {
59
+ exePath = await buildInnoInstaller({
60
+ ...installerArgs,
61
+ options: { ...innoOpts, publisher: innoOpts.publisher ?? result.publisher },
62
+ });
63
+ } else if (nsisWanted && (await hasMakensis())) {
64
+ exePath = await buildNsisInstaller({
65
+ ...installerArgs,
66
+ options: { ...nsisOpts, publisher: nsisOpts.publisher ?? result.publisher },
67
+ });
68
+ }
69
+
70
+ if (exePath) return { installerName: setupName, installerSize: statSync(exePath).size };
71
+ if (innoWanted || nsisWanted) {
72
+ console.warn(
73
+ "[mirin release] no installer toolchain found — shipping the portable .zip. " +
74
+ "Install Inno Setup (`scoop install inno-setup`) or NSIS (`scoop install nsis`).",
75
+ );
76
+ }
77
+
78
+ const installerName = `${prefix}-${safeName}.zip`;
79
+ const zipPath = join(outDir, installerName);
80
+ console.log(`[mirin release] zipping → ${installerName}`);
81
+ rmSync(zipPath, { force: true });
82
+ await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
83
+ return { installerName, installerSize: statSync(zipPath).size };
84
+ }
85
+
86
+ async function buildLinuxInstallers(input: ReleaseInstallerInput): Promise<ReleaseInstallerResult> {
87
+ const { result, outDir } = input;
88
+ if (result.linux === false) return { installerSize: 0 };
89
+
90
+ try {
91
+ const formats = resolveLinuxFormats(result.linux);
92
+ console.log(`[mirin release] building Linux packages (${formats.join(", ")})…`);
93
+ const packages = await buildLinuxPackages({
94
+ appDir: result.app,
95
+ appName: result.appName,
96
+ bundleId: result.bundleId,
97
+ version: result.version,
98
+ publisher: result.publisher,
99
+ outDir,
100
+ projectDir: result.projectDir,
101
+ icon: result.icon,
102
+ options: typeof result.linux === "object" ? result.linux : {},
103
+ formats,
104
+ });
105
+ const primary = packages.find((artifact) => artifact.format === "appimage") ?? packages[0];
106
+ for (const artifact of packages) {
107
+ if (artifact !== primary) {
108
+ console.log(` ${basename(artifact.path)} (${(artifact.size / 1e6).toFixed(1)} MB)`);
109
+ }
110
+ }
111
+ return primary
112
+ ? { installerName: basename(primary.path), installerSize: primary.size }
113
+ : { installerSize: 0 };
114
+ } catch (error) {
115
+ console.warn(
116
+ `\n[mirin release] Linux packaging failed: ${error instanceof Error ? error.message : error}\n` +
117
+ "[mirin release] shipping the updater bundle + manifest WITHOUT packages.\n",
118
+ );
119
+ return { installerSize: 0 };
120
+ }
121
+ }
122
+
123
+ async function buildMacInstaller(input: ReleaseInstallerInput): Promise<ReleaseInstallerResult> {
124
+ const { result, outDir, prefix, safeName } = input;
125
+ const installerName = `${prefix}-${safeName}.dmg`;
126
+ console.log(`[mirin release] building installer → ${installerName}`);
127
+ try {
128
+ const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
129
+ const path = await buildDmg({
130
+ app: result.app,
131
+ appName: result.appName,
132
+ outDir,
133
+ fileName: installerName,
134
+ options,
135
+ projectDir: result.projectDir,
136
+ signIdentity: result.signIdentity,
137
+ });
138
+ await notarizeAndStaple(path);
139
+ return { installerName, installerSize: statSync(path).size };
140
+ } catch (error) {
141
+ console.warn(
142
+ `\n[mirin release] DMG build failed: ${error instanceof Error ? error.message : error}\n` +
143
+ "[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n",
144
+ );
145
+ rmSync(join(outDir, installerName), { force: true });
146
+ return { installerSize: 0 };
147
+ }
148
+ }