@mirinjs/cli 0.1.0-alpha.0 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/build.ts +4 -0
- package/src/dmg.ts +29 -1
- package/src/installer-win.ts +166 -0
- package/src/release.ts +60 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirinjs/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"description": "CLI for mirin apps: dev, build, init.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"bun": ">=1.2.0"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"create-mirinjs": "0.1.0-alpha.
|
|
31
|
-
"mirinjs": "0.1.0-alpha.
|
|
30
|
+
"create-mirinjs": "0.1.0-alpha.2",
|
|
31
|
+
"mirinjs": "0.1.0-alpha.2"
|
|
32
32
|
},
|
|
33
33
|
"optionalDependencies": {
|
|
34
|
-
"@mirinjs/darwin-arm64": "0.1.0-alpha.
|
|
35
|
-
"@mirinjs/win32-x64": "0.1.0-alpha.
|
|
34
|
+
"@mirinjs/darwin-arm64": "0.1.0-alpha.2",
|
|
35
|
+
"@mirinjs/win32-x64": "0.1.0-alpha.2"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
package/src/build.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface BuildResult {
|
|
|
43
43
|
projectDir: string;
|
|
44
44
|
/** DMG config from mirin.config.ts (`true`/object/`false`); default `true`. */
|
|
45
45
|
dmg: boolean | import("mirinjs").DmgConfig;
|
|
46
|
+
/** NSIS installer config (Windows) — `true`/object/`false`; default `true`. */
|
|
47
|
+
nsis: boolean | import("mirinjs").NsisConfig;
|
|
46
48
|
/** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
|
|
47
49
|
signIdentity?: string;
|
|
48
50
|
}
|
|
@@ -73,6 +75,7 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
73
75
|
const channel: string = config.release?.channel ?? "stable";
|
|
74
76
|
const baseUrl: string | undefined = config.release?.baseUrl;
|
|
75
77
|
const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
|
|
78
|
+
const nsis: boolean | import("mirinjs").NsisConfig = config.nsis ?? true;
|
|
76
79
|
|
|
77
80
|
console.log(`[mirin build] ${appName} ${version}`);
|
|
78
81
|
|
|
@@ -146,6 +149,7 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
146
149
|
coreDylib: artifacts.coreDylib,
|
|
147
150
|
projectDir,
|
|
148
151
|
dmg,
|
|
152
|
+
nsis,
|
|
149
153
|
signIdentity,
|
|
150
154
|
};
|
|
151
155
|
}
|
package/src/dmg.ts
CHANGED
|
@@ -93,6 +93,34 @@ export async function notarizeAndStaple(target: string): Promise<boolean> {
|
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* `hdiutil create` with retries. "Resource busy" is a transient CI flake — a
|
|
98
|
+
* Spotlight/antivirus/`fseventsd` process touches the staging folder (or a prior
|
|
99
|
+
* image hasn't fully detached) while hdiutil tries to read it. A short backoff
|
|
100
|
+
* clears it; only a non-busy error fails immediately.
|
|
101
|
+
*/
|
|
102
|
+
async function hdiutilCreate(
|
|
103
|
+
vol: string,
|
|
104
|
+
staging: string,
|
|
105
|
+
format: string,
|
|
106
|
+
dmgPath: string,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
let stderr = "";
|
|
109
|
+
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
110
|
+
const res =
|
|
111
|
+
await $`hdiutil create -volname ${vol} -srcfolder ${staging} -ov -format ${format} ${dmgPath}`
|
|
112
|
+
.quiet()
|
|
113
|
+
.nothrow();
|
|
114
|
+
if (res.exitCode === 0) return;
|
|
115
|
+
stderr = res.stderr.toString().trim();
|
|
116
|
+
if (!/busy/i.test(stderr)) break; // not the transient flake — fail now
|
|
117
|
+
console.warn(`[mirin release] hdiutil create busy (attempt ${attempt}/4); retrying…`);
|
|
118
|
+
rmSync(dmgPath, { force: true });
|
|
119
|
+
await Bun.sleep(3000 * attempt);
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`hdiutil create failed: ${stderr}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
96
124
|
/** Does this config ask for a styled Finder window (vs. a plain DMG)? */
|
|
97
125
|
function wantsLayout(o: DmgOptions): boolean {
|
|
98
126
|
return !!(o.background || o.appPosition || o.applicationsPosition || o.windowSize || o.iconSize);
|
|
@@ -131,7 +159,7 @@ export async function buildDmg(input: BuildDmgInput): Promise<string> {
|
|
|
131
159
|
}
|
|
132
160
|
}
|
|
133
161
|
if (!built) {
|
|
134
|
-
await
|
|
162
|
+
await hdiutilCreate(vol, staging, format, dmgPath);
|
|
135
163
|
}
|
|
136
164
|
} finally {
|
|
137
165
|
rmSync(staging, { recursive: true, force: true });
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows installer (NSIS) for `mirin release` — the Windows analogue of `dmg.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Generates an NSIS script from the app folder + config and compiles it with
|
|
5
|
+
* `makensis` into a single `…-setup.exe`. The installer copies the app into
|
|
6
|
+
* Program Files (per-machine) or `%LOCALAPPDATA%\Programs` (per-user, the
|
|
7
|
+
* default — no elevation, and the in-app updater can swap the folder), creates
|
|
8
|
+
* Start Menu + Desktop shortcuts, writes an uninstaller, and registers an
|
|
9
|
+
* Add/Remove Programs entry. Customizable via {@link NsisConfig}.
|
|
10
|
+
*
|
|
11
|
+
* Requires `makensis` (NSIS 3+) on PATH; the caller checks `hasMakensis()` and
|
|
12
|
+
* falls back to the portable `.zip` when it's absent.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { $ } from "bun";
|
|
16
|
+
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join, isAbsolute } from "node:path";
|
|
18
|
+
import type { NsisConfig } from "mirinjs";
|
|
19
|
+
import { makeWindowsIcon } from "./icon-win.ts";
|
|
20
|
+
|
|
21
|
+
export interface BuildNsisInput {
|
|
22
|
+
/** The assembled app folder (build/<App>). */
|
|
23
|
+
appDir: string;
|
|
24
|
+
appName: string;
|
|
25
|
+
/** Host exe name inside the folder (<App>.exe). */
|
|
26
|
+
exeName: string;
|
|
27
|
+
version: string;
|
|
28
|
+
/** Reverse-DNS app id → the Add/Remove Programs registry key. */
|
|
29
|
+
bundleId: string;
|
|
30
|
+
/** Output directory (build/release). */
|
|
31
|
+
outDir: string;
|
|
32
|
+
/** Output file name (e.g. stable-win32-x64-Anko-setup.exe). */
|
|
33
|
+
fileName: string;
|
|
34
|
+
options: NsisConfig;
|
|
35
|
+
/** Project root, for resolving a relative license / installerIcon. */
|
|
36
|
+
projectDir: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether `makensis` is on PATH. */
|
|
40
|
+
export async function hasMakensis(): Promise<boolean> {
|
|
41
|
+
return (await $`makensis -VERSION`.quiet().nothrow()).exitCode === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** NSIS double-quoted string escaping (`"` and `$` are special). */
|
|
45
|
+
function nsis(s: string): string {
|
|
46
|
+
return s.replace(/\$/g, "$$$$").replace(/"/g, '$\\"');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const resolve = (projectDir: string, p: string) => (isAbsolute(p) ? p : join(projectDir, p));
|
|
50
|
+
|
|
51
|
+
/** Build the installer with `makensis`, returning the setup.exe path. */
|
|
52
|
+
export async function buildNsisInstaller(input: BuildNsisInput): Promise<string> {
|
|
53
|
+
const { appDir, appName, exeName, version, bundleId, outDir, fileName, options, projectDir } = input;
|
|
54
|
+
const out = join(outDir, fileName);
|
|
55
|
+
rmSync(out, { force: true });
|
|
56
|
+
|
|
57
|
+
const perMachine = options.perMachine === true;
|
|
58
|
+
const assisted = options.oneClick !== true;
|
|
59
|
+
const desktop = options.desktopShortcut !== false;
|
|
60
|
+
const startMenu = options.startMenuShortcut !== false;
|
|
61
|
+
const runAfter = options.runAfterFinish !== false;
|
|
62
|
+
const changeDir = options.allowChangeInstallDir !== false;
|
|
63
|
+
const publisher = options.publisher ?? appName;
|
|
64
|
+
const root = perMachine ? "HKLM" : "HKCU";
|
|
65
|
+
const installDir =
|
|
66
|
+
options.installDir ?? (perMachine ? `$PROGRAMFILES64\\${appName}` : `$LOCALAPPDATA\\Programs\\${appName}`);
|
|
67
|
+
|
|
68
|
+
// Installer/uninstaller icon: an explicit .ico, else the app's bundled icon.ico.
|
|
69
|
+
let icon: string | undefined;
|
|
70
|
+
if (options.installerIcon) {
|
|
71
|
+
icon = makeWindowsIcon(resolve(projectDir, options.installerIcon), join(outDir, "_installer.ico"));
|
|
72
|
+
} else if (existsSync(join(appDir, "icon.ico"))) {
|
|
73
|
+
icon = join(appDir, "icon.ico");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const license = options.license ? resolve(projectDir, options.license) : undefined;
|
|
77
|
+
if (license && !existsSync(license)) throw new Error(`nsis: license not found: ${license}`);
|
|
78
|
+
|
|
79
|
+
const L: string[] = [];
|
|
80
|
+
L.push("Unicode True");
|
|
81
|
+
L.push("SetCompressor /SOLID lzma");
|
|
82
|
+
L.push('!include "MUI2.nsh"');
|
|
83
|
+
L.push('!include "FileFunc.nsh"');
|
|
84
|
+
L.push(`Name "${nsis(appName)}"`);
|
|
85
|
+
L.push(`OutFile "${nsis(out)}"`);
|
|
86
|
+
L.push(`InstallDir "${installDir}"`);
|
|
87
|
+
L.push(`RequestExecutionLevel ${perMachine ? "admin" : "user"}`);
|
|
88
|
+
L.push(`!define MUI_ABORTWARNING`);
|
|
89
|
+
if (icon) {
|
|
90
|
+
L.push(`!define MUI_ICON "${nsis(icon)}"`);
|
|
91
|
+
L.push(`!define MUI_UNICON "${nsis(icon)}"`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Pages. Assisted shows the full wizard; one-click is just the progress page.
|
|
95
|
+
if (assisted) L.push("!insertmacro MUI_PAGE_WELCOME");
|
|
96
|
+
if (license) L.push(`!insertmacro MUI_PAGE_LICENSE "${nsis(license)}"`);
|
|
97
|
+
if (assisted && changeDir) L.push("!insertmacro MUI_PAGE_DIRECTORY");
|
|
98
|
+
if (assisted && runAfter) {
|
|
99
|
+
L.push(`!define MUI_FINISHPAGE_RUN "$INSTDIR\\${nsis(exeName)}"`);
|
|
100
|
+
}
|
|
101
|
+
L.push("!insertmacro MUI_PAGE_INSTFILES");
|
|
102
|
+
if (assisted) L.push("!insertmacro MUI_PAGE_FINISH");
|
|
103
|
+
L.push("!insertmacro MUI_UNPAGE_CONFIRM");
|
|
104
|
+
L.push("!insertmacro MUI_UNPAGE_INSTFILES");
|
|
105
|
+
L.push('!insertmacro MUI_LANGUAGE "English"');
|
|
106
|
+
|
|
107
|
+
if (options.include) L.push("", "; --- user include ---", options.include, "");
|
|
108
|
+
|
|
109
|
+
const key = `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${bundleId}`;
|
|
110
|
+
const ctx = perMachine ? "all" : "current";
|
|
111
|
+
|
|
112
|
+
// Install section.
|
|
113
|
+
L.push("Section");
|
|
114
|
+
L.push(` SetShellVarContext ${ctx}`);
|
|
115
|
+
L.push(` SetOutPath "$INSTDIR"`);
|
|
116
|
+
L.push(` File /r "${nsis(appDir)}\\*"`);
|
|
117
|
+
L.push(` WriteUninstaller "$INSTDIR\\Uninstall.exe"`);
|
|
118
|
+
if (desktop) {
|
|
119
|
+
L.push(` CreateShortcut "$DESKTOP\\${nsis(appName)}.lnk" "$INSTDIR\\${nsis(exeName)}"`);
|
|
120
|
+
}
|
|
121
|
+
if (startMenu) {
|
|
122
|
+
L.push(` CreateDirectory "$SMPROGRAMS\\${nsis(appName)}"`);
|
|
123
|
+
L.push(
|
|
124
|
+
` CreateShortcut "$SMPROGRAMS\\${nsis(appName)}\\${nsis(appName)}.lnk" "$INSTDIR\\${nsis(exeName)}"`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// Estimated size for Add/Remove Programs.
|
|
128
|
+
L.push(` \${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2`);
|
|
129
|
+
L.push(` IntFmt $0 "0x%08X" $0`);
|
|
130
|
+
const reg = (name: string, val: string) => L.push(` WriteRegStr ${root} "${key}" "${name}" "${val}"`);
|
|
131
|
+
reg("DisplayName", nsis(appName));
|
|
132
|
+
reg("DisplayVersion", nsis(version));
|
|
133
|
+
reg("Publisher", nsis(publisher));
|
|
134
|
+
reg("DisplayIcon", `$INSTDIR\\${nsis(exeName)}`);
|
|
135
|
+
reg("InstallLocation", "$INSTDIR");
|
|
136
|
+
reg("UninstallString", '$INSTDIR\\Uninstall.exe');
|
|
137
|
+
reg("QuietUninstallString", '$INSTDIR\\Uninstall.exe /S');
|
|
138
|
+
L.push(` WriteRegDWORD ${root} "${key}" "NoModify" 1`);
|
|
139
|
+
L.push(` WriteRegDWORD ${root} "${key}" "NoRepair" 1`);
|
|
140
|
+
L.push(` WriteRegDWORD ${root} "${key}" "EstimatedSize" $0`);
|
|
141
|
+
if (!assisted && runAfter) L.push(` Exec '"$INSTDIR\\${nsis(exeName)}"'`);
|
|
142
|
+
L.push("SectionEnd");
|
|
143
|
+
|
|
144
|
+
// Uninstall section.
|
|
145
|
+
L.push("Section \"Uninstall\"");
|
|
146
|
+
L.push(` SetShellVarContext ${ctx}`);
|
|
147
|
+
if (desktop) L.push(` Delete "$DESKTOP\\${nsis(appName)}.lnk"`);
|
|
148
|
+
if (startMenu) {
|
|
149
|
+
L.push(` Delete "$SMPROGRAMS\\${nsis(appName)}\\${nsis(appName)}.lnk"`);
|
|
150
|
+
L.push(` RMDir "$SMPROGRAMS\\${nsis(appName)}"`);
|
|
151
|
+
}
|
|
152
|
+
L.push(` RMDir /r "$INSTDIR"`);
|
|
153
|
+
L.push(` DeleteRegKey ${root} "${key}"`);
|
|
154
|
+
L.push("SectionEnd");
|
|
155
|
+
|
|
156
|
+
const script = join(outDir, "_installer.nsi");
|
|
157
|
+
writeFileSync(script, L.join("\n") + "\n");
|
|
158
|
+
console.log(`[mirin release] compiling NSIS installer → ${fileName}`);
|
|
159
|
+
const res = await $`makensis -V2 ${script}`.nothrow();
|
|
160
|
+
rmSync(script, { force: true });
|
|
161
|
+
rmSync(join(outDir, "_installer.ico"), { force: true });
|
|
162
|
+
if (res.exitCode !== 0 || !existsSync(out)) {
|
|
163
|
+
throw new Error(`makensis failed (exit ${res.exitCode})`);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
package/src/release.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { join } 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
|
+
import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
|
|
25
26
|
import { loadCodec } from "mirinjs/codec";
|
|
26
27
|
|
|
27
28
|
const sha256File = (path: string) =>
|
|
@@ -129,28 +130,66 @@ export async function release(projectDir = process.cwd()): Promise<number> {
|
|
|
129
130
|
let installerName: string | undefined;
|
|
130
131
|
let installerSize = 0;
|
|
131
132
|
if (isWindows) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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,
|
|
149
|
+
});
|
|
150
|
+
installerSize = readFileSync(exePath).byteLength;
|
|
151
|
+
} else {
|
|
152
|
+
if (wantsInstaller) {
|
|
153
|
+
console.warn(
|
|
154
|
+
"[mirin release] makensis (NSIS) not found — shipping the portable .zip. " +
|
|
155
|
+
"Install NSIS (e.g. `scoop install nsis`) for an installer.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
installerName = `${prefix}-${safeName}.zip`;
|
|
159
|
+
console.log(`[mirin release] zipping → ${installerName}`);
|
|
160
|
+
const zipPath = join(outDir, installerName);
|
|
161
|
+
rmSync(zipPath, { force: true });
|
|
162
|
+
// bsdtar (Windows `tar`) creates a .zip from the extension with `-a`.
|
|
163
|
+
await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
|
|
164
|
+
installerSize = readFileSync(zipPath).byteLength;
|
|
165
|
+
}
|
|
139
166
|
} else if (result.dmg !== false) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
167
|
+
// The DMG is the first-install convenience; the updater bundle + manifest (the
|
|
168
|
+
// critical artifacts) are already built. Never fail the whole release over a
|
|
169
|
+
// flaky installer step — warn loudly and ship the rest.
|
|
170
|
+
const dmgName = `${prefix}-${safeName}.dmg`;
|
|
171
|
+
console.log(`[mirin release] building installer → ${dmgName}`);
|
|
172
|
+
try {
|
|
173
|
+
const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
|
|
174
|
+
const dmgPath = await buildDmg({
|
|
175
|
+
app: result.app,
|
|
176
|
+
appName: result.appName,
|
|
177
|
+
outDir,
|
|
178
|
+
fileName: dmgName,
|
|
179
|
+
options,
|
|
180
|
+
projectDir: result.projectDir,
|
|
181
|
+
signIdentity: result.signIdentity,
|
|
182
|
+
});
|
|
183
|
+
await notarizeAndStaple(dmgPath); // no-op without notary credentials
|
|
184
|
+
installerName = dmgName;
|
|
185
|
+
installerSize = readFileSync(dmgPath).byteLength;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
console.warn(
|
|
188
|
+
`\n[mirin release] ⚠️ DMG build failed: ${e instanceof Error ? e.message : e}\n` +
|
|
189
|
+
`[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n`,
|
|
190
|
+
);
|
|
191
|
+
rmSync(join(outDir, dmgName), { force: true });
|
|
192
|
+
}
|
|
154
193
|
}
|
|
155
194
|
|
|
156
195
|
const mb = (n: number) => (n / 1e6).toFixed(1);
|