@mirinjs/cli 0.0.1-alpha.13 → 0.0.1-alpha.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirinjs/cli",
3
- "version": "0.0.1-alpha.13",
3
+ "version": "0.0.1-alpha.14",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,11 +27,11 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.0.1-alpha.13",
31
- "mirinjs": "0.0.1-alpha.13"
30
+ "create-mirinjs": "0.0.1-alpha.14",
31
+ "mirinjs": "0.0.1-alpha.14"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@mirinjs/darwin-arm64": "0.0.1-alpha.13"
34
+ "@mirinjs/darwin-arm64": "0.0.1-alpha.14"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
package/src/build.ts CHANGED
@@ -35,6 +35,12 @@ export interface BuildResult {
35
35
  baseUrl?: string;
36
36
  /** libmirin_core path (for the updater codec at release time). */
37
37
  coreDylib: string;
38
+ /** Project root (so `mirin release` can resolve relative asset paths). */
39
+ projectDir: string;
40
+ /** DMG config from mirin.config.ts (`true`/object/`false`); default `true`. */
41
+ dmg: boolean | import("mirinjs").DmgConfig;
42
+ /** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
43
+ signIdentity?: string;
38
44
  }
39
45
 
40
46
  /** Read the project's package.json version (the single source of app version). */
@@ -62,6 +68,7 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
62
68
  const version = appVersion(projectDir);
63
69
  const channel: string = config.release?.channel ?? "stable";
64
70
  const baseUrl: string | undefined = config.release?.baseUrl;
71
+ const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
65
72
 
66
73
  console.log(`[mirin build] ${appName} ${version}`);
67
74
 
@@ -74,6 +81,7 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
74
81
 
75
82
  // 3 + 4. host + worker (minified)
76
83
  console.log("[mirin build] compiling host + bundling main process…");
84
+ const signIdentity = process.env.MIRIN_SIGN_IDENTITY;
77
85
  const hostExe = join(work, "host-release");
78
86
  const workerJs = join(work, "worker.release.js");
79
87
  await $`bun build --compile --minify ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
@@ -97,7 +105,7 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
97
105
  cefPath: artifacts.cefPath,
98
106
  version,
99
107
  icon: config.icon ? join(projectDir, config.icon) : undefined,
100
- signIdentity: process.env.MIRIN_SIGN_IDENTITY,
108
+ signIdentity,
101
109
  resources: {
102
110
  uiDir: join(projectDir, "dist"),
103
111
  workerJs,
@@ -108,5 +116,16 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
108
116
 
109
117
  console.log(`\n[mirin build] done → ${app}`);
110
118
  console.log(` open "${app}"`);
111
- return { app, appName, bundleId, version, channel, baseUrl, coreDylib: artifacts.coreDylib };
119
+ return {
120
+ app,
121
+ appName,
122
+ bundleId,
123
+ version,
124
+ channel,
125
+ baseUrl,
126
+ coreDylib: artifacts.coreDylib,
127
+ projectDir,
128
+ dmg,
129
+ signIdentity,
130
+ };
112
131
  }
package/src/dmg.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * macOS `.dmg` installer for `mirin release`.
3
+ *
4
+ * Two paths:
5
+ * - **plain** (default): stage the `.app` + an `/Applications` symlink, then
6
+ * `hdiutil create … -format ULFO`. Rock-solid in CI — this is what ships when
7
+ * `dmg: true`. ULFO (lzfse) handles large CEF frameworks best on modern macOS.
8
+ * - **laid-out**: when a `background` or any window/icon position is configured,
9
+ * build a read-write image, style the Finder window via AppleScript, then
10
+ * convert to the compressed read-only format. Best-effort — falls back to the
11
+ * plain DMG if Finder automation isn't available (e.g. a headless runner).
12
+ *
13
+ * The DMG is codesigned (a DMG carries no entitlements / hardened runtime — it's
14
+ * not executable code) and, when notary credentials are present, notarized +
15
+ * stapled so a fresh download opens without a Gatekeeper prompt.
16
+ */
17
+
18
+ import { $ } from "bun";
19
+ import { cpSync, mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
20
+ import { join, basename, isAbsolute } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ export interface DmgOptions {
24
+ volumeName?: string;
25
+ format?: "ULFO" | "UDZO" | "UDBZ";
26
+ background?: string;
27
+ windowSize?: { width: number; height: number };
28
+ iconSize?: number;
29
+ appPosition?: { x: number; y: number };
30
+ applicationsPosition?: { x: number; y: number };
31
+ }
32
+
33
+ export interface BuildDmgInput {
34
+ /** Path to the (signed) `.app`. */
35
+ app: string;
36
+ appName: string;
37
+ /** Output directory for the `.dmg`. */
38
+ outDir: string;
39
+ /** Output file name (e.g. `stable-darwin-arm64-Anko.dmg`). */
40
+ fileName: string;
41
+ /** Resolved DMG options. */
42
+ options: DmgOptions;
43
+ /** Project root, for resolving a relative `background` path. */
44
+ projectDir: string;
45
+ /** Codesign identity ("-"/undefined → ad-hoc, no notarization). */
46
+ signIdentity?: string;
47
+ }
48
+
49
+ /** hdiutil volume names choke on some punctuation; keep it tame but readable. */
50
+ function volumeName(name: string): string {
51
+ return name.replace(/[/:\\]/g, " ").trim() || "App";
52
+ }
53
+
54
+ /**
55
+ * Notarize + staple a `.app` or `.dmg` using MIRIN_NOTARY_* env credentials.
56
+ * No-op (returns false) when credentials are absent. Throws on rejection,
57
+ * printing the notary log so the failure is diagnosable.
58
+ */
59
+ export async function notarizeAndStaple(target: string): Promise<boolean> {
60
+ const apple = process.env.MIRIN_NOTARY_APPLE_ID;
61
+ const pw = process.env.MIRIN_NOTARY_PASSWORD;
62
+ const team = process.env.MIRIN_NOTARY_TEAM_ID;
63
+ if (!apple || !pw || !team) return false;
64
+
65
+ const isApp = target.endsWith(".app");
66
+ // notarytool wants a zip for a bundle; a .dmg is submitted as-is.
67
+ const submitPath = isApp ? `${target}.notarize.zip` : target;
68
+ if (isApp) await $`ditto -c -k --keepParent ${target} ${submitPath}`;
69
+
70
+ console.log(`[mirin release] notarizing ${basename(target)} (this can take a few minutes)…`);
71
+ const out =
72
+ await $`xcrun notarytool submit ${submitPath} --apple-id ${apple} --password ${pw} --team-id ${team} --wait --output-format json`.text();
73
+ if (isApp) rmSync(submitPath, { force: true });
74
+
75
+ let sub: { id?: string; status?: string } = {};
76
+ try {
77
+ sub = JSON.parse(out);
78
+ } catch {
79
+ console.error(out);
80
+ }
81
+ if (sub.status !== "Accepted") {
82
+ console.error(`[mirin release] notarization ${sub.status ?? "failed"} (id: ${sub.id ?? "?"})`);
83
+ if (sub.id) {
84
+ const log =
85
+ await $`xcrun notarytool log ${sub.id} --apple-id ${apple} --password ${pw} --team-id ${team}`
86
+ .text()
87
+ .catch(() => "");
88
+ if (log) console.error(log);
89
+ }
90
+ throw new Error(`notarization not accepted: ${sub.status ?? "unknown"}`);
91
+ }
92
+ await $`xcrun stapler staple ${target}`;
93
+ return true;
94
+ }
95
+
96
+ /** Does this config ask for a styled Finder window (vs. a plain DMG)? */
97
+ function wantsLayout(o: DmgOptions): boolean {
98
+ return !!(o.background || o.appPosition || o.applicationsPosition || o.windowSize || o.iconSize);
99
+ }
100
+
101
+ /**
102
+ * Build (and codesign) the `.dmg`, returning its path. Notarization is the
103
+ * caller's responsibility (so it can be sequenced with the other artifacts).
104
+ */
105
+ export async function buildDmg(input: BuildDmgInput): Promise<string> {
106
+ const { app, appName, outDir, fileName, options, projectDir, signIdentity } = input;
107
+ const vol = volumeName(options.volumeName ?? appName);
108
+ const format = options.format ?? "ULFO";
109
+ const dmgPath = join(outDir, fileName);
110
+ rmSync(dmgPath, { force: true });
111
+
112
+ const staging = join(tmpdir(), `mirin-dmg-${process.pid}`);
113
+ rmSync(staging, { recursive: true, force: true });
114
+ mkdirSync(staging, { recursive: true });
115
+ try {
116
+ // BSD cp -R preserves the bundle's symlinks/signatures.
117
+ await $`cp -R ${app} ${join(staging, basename(app))}`;
118
+ symlinkSync("/Applications", join(staging, "Applications"));
119
+
120
+ let built = false;
121
+ if (wantsLayout(options)) {
122
+ try {
123
+ await buildLaidOutDmg({ staging, vol, format, dmgPath, options, projectDir, appFile: basename(app) });
124
+ built = true;
125
+ } catch (e) {
126
+ console.warn(
127
+ `[mirin release] DMG layout failed (${e instanceof Error ? e.message : e}); ` +
128
+ `falling back to a plain DMG.`,
129
+ );
130
+ rmSync(dmgPath, { force: true });
131
+ }
132
+ }
133
+ if (!built) {
134
+ await $`hdiutil create -volname ${vol} -srcfolder ${staging} -ov -format ${format} ${dmgPath}`.quiet();
135
+ }
136
+ } finally {
137
+ rmSync(staging, { recursive: true, force: true });
138
+ }
139
+
140
+ // Sign the image itself (Gatekeeper checks the DMG's signature too).
141
+ if (signIdentity && signIdentity !== "-") {
142
+ await $`codesign --force --timestamp --sign ${signIdentity} ${dmgPath}`.quiet();
143
+ } else if (signIdentity === "-") {
144
+ await $`codesign --force --sign - ${dmgPath}`.quiet();
145
+ }
146
+ return dmgPath;
147
+ }
148
+
149
+ /**
150
+ * Read-write image → style the Finder window via AppleScript → convert to the
151
+ * compressed read-only `format`. Throws if any step fails (caller falls back).
152
+ */
153
+ async function buildLaidOutDmg(args: {
154
+ staging: string;
155
+ vol: string;
156
+ format: string;
157
+ dmgPath: string;
158
+ options: DmgOptions;
159
+ projectDir: string;
160
+ appFile: string;
161
+ }): Promise<void> {
162
+ const { staging, vol, format, dmgPath, options, projectDir, appFile } = args;
163
+ const win = options.windowSize ?? { width: 640, height: 400 };
164
+ const iconSize = options.iconSize ?? 128;
165
+ const appPos = options.appPosition ?? { x: Math.round(win.width * 0.25), y: Math.round(win.height * 0.5) };
166
+ const appsPos =
167
+ options.applicationsPosition ?? { x: Math.round(win.width * 0.75), y: Math.round(win.height * 0.5) };
168
+
169
+ // Stage a background image (if any) under a hidden folder Finder can reference.
170
+ let bgFile: string | undefined;
171
+ if (options.background) {
172
+ const src = isAbsolute(options.background)
173
+ ? options.background
174
+ : join(projectDir, options.background);
175
+ if (!existsSync(src)) throw new Error(`background not found: ${src}`);
176
+ const bgDir = join(staging, ".background");
177
+ mkdirSync(bgDir, { recursive: true });
178
+ const ext = src.slice(src.lastIndexOf("."));
179
+ bgFile = `bg${ext}`;
180
+ cpSync(src, join(bgDir, bgFile));
181
+ }
182
+
183
+ const rw = `${dmgPath}.rw.dmg`;
184
+ rmSync(rw, { force: true });
185
+ // Read-write image sized to the staged contents, with slack for HFS overhead.
186
+ await $`hdiutil create -volname ${vol} -srcfolder ${staging} -fs HFS+ -format UDRW -ov ${rw}`.quiet();
187
+
188
+ // Attach without auto-opening a Finder window; parse the real mountpoint.
189
+ const attach =
190
+ await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
191
+ const mount = attach
192
+ .split("\n")
193
+ .map((l) => l.trim())
194
+ .find((l) => l.includes("/Volumes/"))
195
+ ?.split("\t")
196
+ .pop()
197
+ ?.trim();
198
+ if (!mount || !existsSync(mount)) {
199
+ await $`hdiutil detach ${rw}`.quiet().catch(() => {});
200
+ throw new Error("could not resolve DMG mountpoint");
201
+ }
202
+
203
+ try {
204
+ const bgClause = bgFile
205
+ ? `set background picture of viewOptions to file ".background:${bgFile}"`
206
+ : "";
207
+ const script = `
208
+ tell application "Finder"
209
+ tell disk "${vol}"
210
+ open
211
+ set current view of container window to icon view
212
+ set toolbar visible of container window to false
213
+ set statusbar visible of container window to false
214
+ set the bounds of container window to {200, 120, ${200 + win.width}, ${120 + win.height}}
215
+ set viewOptions to the icon view options of container window
216
+ set arrangement of viewOptions to not arranged
217
+ set icon size of viewOptions to ${iconSize}
218
+ ${bgClause}
219
+ set position of item "${appFile}" of container window to {${appPos.x}, ${appPos.y}}
220
+ set position of item "Applications" of container window to {${appsPos.x}, ${appsPos.y}}
221
+ update without registering applications
222
+ delay 1
223
+ close
224
+ end tell
225
+ end tell`;
226
+ await $`osascript -e ${script}`.quiet();
227
+ await $`sync`.quiet().catch(() => {});
228
+ } finally {
229
+ await $`hdiutil detach ${mount}`.quiet().catch(async () => {
230
+ await $`hdiutil detach ${rw} -force`.quiet().catch(() => {});
231
+ });
232
+ }
233
+
234
+ await $`hdiutil convert ${rw} -format ${format} -ov -o ${dmgPath}`.quiet();
235
+ rmSync(rw, { force: true });
236
+ }
package/src/release.ts CHANGED
@@ -21,6 +21,7 @@ import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { tmpdir } from "node:os";
23
23
  import { build } from "./build.ts";
24
+ import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
24
25
  import { loadCodec } from "mirinjs/codec";
25
26
 
26
27
  const sha256File = (path: string) =>
@@ -44,39 +45,10 @@ export async function release(projectDir = process.cwd()): Promise<number> {
44
45
  rmSync(outDir, { recursive: true, force: true });
45
46
  mkdirSync(outDir, { recursive: true });
46
47
 
47
- // Optional notarize + staple (Developer ID) before packing, when configured.
48
- const apple = process.env.MIRIN_NOTARY_APPLE_ID;
49
- const pw = process.env.MIRIN_NOTARY_PASSWORD;
50
- const team = process.env.MIRIN_NOTARY_TEAM_ID;
51
- if (apple && pw && team) {
52
- console.log("[mirin release] notarizing (this can take a few minutes)…");
53
- const zip = join(buildDir, "_notarize.zip");
54
- await $`ditto -c -k --keepParent ${result.app} ${zip}`;
55
- // `notarytool submit --wait` exits 0 even when the result is "Invalid", so
56
- // parse the JSON status ourselves and surface the notary log on rejection —
57
- // otherwise the only symptom is a confusing `stapler` failure downstream.
58
- const out =
59
- await $`xcrun notarytool submit ${zip} --apple-id ${apple} --password ${pw} --team-id ${team} --wait --output-format json`.text();
60
- rmSync(zip, { force: true });
61
- let sub: { id?: string; status?: string } = {};
62
- try {
63
- sub = JSON.parse(out);
64
- } catch {
65
- console.error(out);
66
- }
67
- if (sub.status !== "Accepted") {
68
- console.error(`[mirin release] notarization ${sub.status ?? "failed"} (id: ${sub.id ?? "?"})`);
69
- if (sub.id) {
70
- const log =
71
- await $`xcrun notarytool log ${sub.id} --apple-id ${apple} --password ${pw} --team-id ${team}`
72
- .text()
73
- .catch(() => "");
74
- if (log) console.error(log);
75
- }
76
- throw new Error(`notarization not accepted: ${sub.status ?? "unknown"}`);
77
- }
78
- await $`xcrun stapler staple ${result.app}`;
79
- }
48
+ // Notarize + staple the .app (Developer ID) before packing, when credentials
49
+ // are present. The .tar.zst / patch updater bundles are made from this signed,
50
+ // stapled .app, so the updater swaps in an already-notarized app.
51
+ await notarizeAndStaple(result.app);
80
52
 
81
53
  const codec = loadCodec(result.coreDylib);
82
54
 
@@ -141,11 +113,33 @@ export async function release(projectDir = process.cwd()): Promise<number> {
141
113
  const manifestName = `${prefix}-update.json`;
142
114
  await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
143
115
 
116
+ // Distributable installer: a drag-to-Applications .dmg of the same signed,
117
+ // stapled .app — for first-time installs (the updater uses the .tar.zst/patch).
118
+ let dmgName: string | undefined;
119
+ let dmgSize = 0;
120
+ if (result.dmg !== false) {
121
+ dmgName = `${prefix}-${safeName}.dmg`;
122
+ console.log(`[mirin release] building installer → ${dmgName}`);
123
+ const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
124
+ const dmgPath = await buildDmg({
125
+ app: result.app,
126
+ appName: result.appName,
127
+ outDir,
128
+ fileName: dmgName,
129
+ options,
130
+ projectDir: result.projectDir,
131
+ signIdentity: result.signIdentity,
132
+ });
133
+ await notarizeAndStaple(dmgPath); // no-op without notary credentials
134
+ dmgSize = readFileSync(dmgPath).byteLength;
135
+ }
136
+
144
137
  const mb = (n: number) => (n / 1e6).toFixed(1);
145
138
  console.log(`\n[mirin release] done → build/release/`);
146
139
  console.log(` ${manifestName}`);
147
140
  console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
148
141
  for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
142
+ if (dmgName) console.log(` ${dmgName} (${mb(dmgSize)} MB installer)`);
149
143
  console.log(`\nUpload all of build/release/ to: ${result.baseUrl}`);
150
144
  if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
151
145
  return 0;