@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.22

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.
Files changed (39) hide show
  1. package/package.json +8 -5
  2. package/src/artifacts.ts +165 -26
  3. package/src/build.ts +236 -43
  4. package/src/bundle/linux/app.ts +275 -0
  5. package/src/bundle/linux/index.ts +1 -0
  6. package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
  7. package/src/bundle/macos/index.ts +1 -0
  8. package/src/bundle/shared/cef-locales.ts +91 -0
  9. package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
  10. package/src/bundle/windows/index.ts +1 -0
  11. package/src/check.ts +271 -0
  12. package/src/dev.ts +214 -35
  13. package/src/dmg.ts +22 -10
  14. package/src/doctor.ts +227 -0
  15. package/src/extras.ts +52 -13
  16. package/src/icons/macos/appearance.ts +96 -0
  17. package/src/icons/macos/index.ts +1 -0
  18. package/src/icons/sources.ts +25 -0
  19. package/src/icons/windows/icon.ts +255 -0
  20. package/src/icons/windows/index.ts +1 -0
  21. package/src/index.ts +109 -7
  22. package/src/installer-inno.ts +141 -0
  23. package/src/installer-win.ts +17 -11
  24. package/src/package/linux/index.ts +2 -0
  25. package/src/package/linux/lib/defaults.ts +53 -0
  26. package/src/package/linux/lib/tools.ts +45 -0
  27. package/src/package/linux/lib/validation.ts +71 -0
  28. package/src/package/linux/package.ts +293 -0
  29. package/src/package/linux/types.ts +30 -0
  30. package/src/release/installers.ts +148 -0
  31. package/src/release/previous.ts +105 -0
  32. package/src/release.ts +166 -116
  33. package/src/shared/driver.ts +240 -0
  34. package/src/shared/inspector.ts +64 -0
  35. package/src/shared/report.ts +69 -0
  36. package/src/shared/scenario.ts +58 -0
  37. package/src/shared/session.ts +153 -0
  38. package/src/skill.ts +51 -0
  39. package/src/icon-win.ts +0 -81
package/src/dev.ts CHANGED
@@ -6,25 +6,62 @@
6
6
  * 3. assemble + ad-hoc-sign the dev .app
7
7
  * 4. start the Vite dev server (rolldown-vite) for the UI
8
8
  * 5. launch the app pointed at the Vite URL, with RPC injected into the webview
9
+ *
10
+ * Each run also opens a dev session under `.mirin/dev/` and records its phase
11
+ * timeline there (docs/agent-devtools.md).
9
12
  */
10
13
 
11
- import { $ } from "bun";
12
- import { mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
14
+ import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
13
15
  import { createServer } from "node:net";
16
+ import { homedir } from "node:os";
14
17
  import { join } from "node:path";
15
- import { buildAppBundle } from "./bundle.ts";
16
- import { buildWindowsBundle } from "./bundle-win.ts";
18
+ import { $ } from "bun";
17
19
  import { resolveArtifacts } from "./artifacts.ts";
20
+ import {
21
+ buildLinuxBundle,
22
+ desktopEntry,
23
+ resolveLinuxDesktopIconPng,
24
+ resolveLinuxIconPng,
25
+ } from "./bundle/linux/index.ts";
26
+ import { buildAppBundle } from "./bundle/macos/index.ts";
27
+ import { buildWindowsBundle } from "./bundle/windows/index.ts";
18
28
 
19
29
  const IS_WINDOWS = process.platform === "win32";
30
+ const IS_LINUX = process.platform === "linux";
31
+
32
+ import { compileWorkers, normalizeSidecars } from "./extras.ts";
33
+ import { resolveIconSource } from "./icons/sources.ts";
34
+ import { createReporter } from "./shared/report.ts";
35
+ import { DevSession } from "./shared/session.ts";
20
36
  import { sweepBuildTemps } from "./temps.ts";
21
- import { normalizeSidecars, compileWorkers } from "./extras.ts";
22
37
 
23
38
  /** Vite's default port. `mirin dev` probes upward from here for a free one, so a
24
39
  * second dev session (or anything already on 5173) doesn't collide. */
25
40
  const DEV_PORT_BASE = 5173;
26
41
 
27
- export async function dev(projectDir = process.cwd()): Promise<number> {
42
+ /** Where the CEF DevTools-protocol port search starts. Well clear of Vite's range
43
+ * so concurrent dev sessions of different apps don't fight over either. */
44
+ const CDP_PORT_BASE = 9222;
45
+
46
+ export interface DevLaunchContext {
47
+ session?: DevSession;
48
+ devUrl: string;
49
+ cdpPort: number;
50
+ pid: number;
51
+ /** Stop the app and the Vite server. */
52
+ stop(): void;
53
+ }
54
+
55
+ export interface DevOptions {
56
+ /** Emit newline-delimited JSON progress instead of prose. */
57
+ json?: boolean;
58
+ /** Called once the app is running. The app is always stopped after the hook
59
+ * settles, whether it threw or not. */
60
+ onLaunched?(context: DevLaunchContext): Promise<void> | void;
61
+ }
62
+
63
+ export async function dev(projectDir = process.cwd(), options?: DevOptions): Promise<number> {
64
+ const reporter = createReporter(options?.json === true);
28
65
  const work = join(projectDir, ".mirin");
29
66
  mkdirSync(work, { recursive: true });
30
67
  // `bun build --compile` drops temp *.bun-build files in the cwd; clear any left
@@ -36,22 +73,46 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
36
73
  const appName: string = config.name ?? "Mirin App";
37
74
  const bundleId: string = config.id ?? "dev.mirin.app";
38
75
  const mainEntry = join(projectDir, config.main ?? "main/main.ts");
76
+ const iconSrc = resolveIconSource(config.icon, projectDir);
77
+ // Linux: resolve the app icon to a concrete PNG the core stamps onto the window
78
+ // as `_NET_WM_ICON` (cosmic dock/taskbar). Passed straight through in dev — no
79
+ // bundling; the file is read from the project at window-create time.
80
+ const linuxIconPng = IS_LINUX && iconSrc ? resolveLinuxIconPng(iconSrc) : undefined;
81
+
82
+ // --- open the dev session (agent devtools) ---
83
+ const session = DevSession.create({
84
+ projectDir,
85
+ appName,
86
+ appId: bundleId,
87
+ version: typeof config.version === "string" ? config.version : undefined,
88
+ });
39
89
 
40
90
  // --- native artifacts ---
41
- const artifacts = await resolveArtifacts({ release: false });
91
+ const artifacts = await resolveArtifacts({ release: false, reporter });
42
92
 
43
93
  // --- compile the Bun host + bundle the Worker ---
44
- console.log("[mirin dev] compiling host + bundling main process…");
94
+ const compilePhase = session?.phase("compile");
95
+ reporter.info("[mirin dev] compiling host + bundling main process…");
96
+ reporter.event("compile");
45
97
  // `bun build --compile` emits an `.exe` on Windows; name it so explicitly.
46
98
  const hostExe = join(work, IS_WINDOWS ? "host.exe" : "host");
47
99
  const workerJs = join(work, "worker.js");
48
- await $`bun build --compile ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
49
- await $`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir);
100
+ const hostTarget =
101
+ process.platform === "win32" && process.arch === "arm64" ? ["--target=bun-windows-x64"] : [];
102
+ await reporter.build(
103
+ $`bun build --compile ${hostTarget} ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(
104
+ projectDir,
105
+ ),
106
+ );
107
+ await reporter.build(
108
+ $`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir),
109
+ );
110
+ compilePhase?.ok();
50
111
 
51
112
  // Extra assets (dev): compile workers into .mirin/workers and symlink sidecar
52
113
  // binaries into .mirin/sidecars (no copy/sign in dev — they run unsigned locally).
53
114
  const workersDir = join(work, "workers");
54
- await compileWorkers(projectDir, config.workers, workersDir, false);
115
+ await compileWorkers(projectDir, config.workers, workersDir, false, reporter);
55
116
  const sidecarsDir = join(work, "sidecars");
56
117
  mkdirSync(sidecarsDir, { recursive: true });
57
118
  for (const sc of normalizeSidecars(projectDir, config.sidecars)) {
@@ -61,8 +122,10 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
61
122
  else console.warn(`[mirin dev] sidecar "${sc.name}" not found: ${sc.src}`);
62
123
  }
63
124
 
64
- // --- assemble the dev bundle (Windows app folder, or macOS .app) ---
65
- console.log("[mirin dev] assembling dev bundle…");
125
+ // --- assemble the dev bundle (Windows/Linux app folder, or macOS .app) ---
126
+ const bundlePhase = session?.phase("bundle");
127
+ reporter.info("[mirin dev] assembling dev bundle…");
128
+ reporter.event("bundle");
66
129
  const { app, exe } = IS_WINDOWS
67
130
  ? await buildWindowsBundle({
68
131
  appName,
@@ -71,56 +134,154 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
71
134
  coreDll: artifacts.coreDylib,
72
135
  helperExe: artifacts.helperBin,
73
136
  cefPath: artifacts.cefPath,
74
- icon: config.icon ? join(projectDir, config.icon) : undefined,
137
+ icon: iconSrc,
75
138
  })
76
- : await buildAppBundle({
77
- appName,
78
- bundleId,
79
- outDir: work,
80
- hostExe,
81
- coreDylib: artifacts.coreDylib,
82
- helperBin: artifacts.helperBin,
83
- cefPath: artifacts.cefPath,
84
- icon: config.icon ? join(projectDir, config.icon) : undefined,
85
- urlSchemes: config.urlSchemes,
86
- });
139
+ : IS_LINUX
140
+ ? await buildLinuxBundle({
141
+ appName,
142
+ outDir: work,
143
+ hostExe,
144
+ coreDll: artifacts.coreDylib,
145
+ helperExe: artifacts.helperBin,
146
+ cefPath: artifacts.cefPath,
147
+ icon: iconSrc,
148
+ })
149
+ : await buildAppBundle({
150
+ appName,
151
+ bundleId,
152
+ outDir: work,
153
+ hostExe,
154
+ coreDylib: artifacts.coreDylib,
155
+ helperBin: artifacts.helperBin,
156
+ cefPath: artifacts.cefPath,
157
+ icon: iconSrc,
158
+ urlSchemes: config.urlSchemes,
159
+ });
160
+ bundlePhase?.ok();
161
+
162
+ // Linux: install a dev `.desktop` entry so cosmic's dock can resolve the app's
163
+ // icon. cosmic matches a running X11 window's `WM_CLASS` (which the core sets to
164
+ // the bundle id) against `StartupWMClass`, then shows that entry's `Icon`. Written
165
+ // to the per-user applications dir; harmless on other DEs.
166
+ if (IS_LINUX && iconSrc) {
167
+ const desktopIcon = resolveLinuxDesktopIconPng(iconSrc);
168
+ if (desktopIcon) {
169
+ const appsDir = join(homedir(), ".local", "share", "applications");
170
+ mkdirSync(appsDir, { recursive: true });
171
+ writeFileSync(
172
+ join(appsDir, `${bundleId}.desktop`),
173
+ desktopEntry({ name: appName, exec: exe, iconPng: desktopIcon, wmClass: bundleId }),
174
+ );
175
+ }
176
+ }
87
177
 
88
178
  // --- start Vite on a free port so concurrent dev sessions don't collide ---
89
179
  // `--port <free> --strictPort` pins Vite to the port we probed (overriding any
90
180
  // port/strictPort in the app's vite.config) and passes the real URL to the app,
91
181
  // so the host and Vite never disagree about which port is in use.
182
+ //
183
+ // Bind + address on 127.0.0.1 (IPv4), NOT "localhost": on hosts where Vite binds
184
+ // IPv6-only (`[::1]`) while Chromium resolves `localhost` to IPv4 (common on
185
+ // Linux), the webview's page load is refused and the window stays blank. Pinning
186
+ // both sides to 127.0.0.1 avoids the family mismatch (and matches the loopback
187
+ // RPC server). `--host 127.0.0.1` forces Vite's bind.
92
188
  const port = await findFreePort(DEV_PORT_BASE);
93
- const devUrl = `http://localhost:${port}`;
94
- console.log(`[mirin dev] starting Vite dev server on ${devUrl}…`);
189
+ const cdpPort = await findFreePort(CDP_PORT_BASE);
190
+ const devUrl = `http://127.0.0.1:${port}`;
191
+ const vitePhase = session?.phase("vite");
192
+ session?.setDevUrl(devUrl);
193
+ reporter.info(`[mirin dev] starting Vite dev server on ${devUrl}…`);
194
+ reporter.event("vite", { devUrl });
95
195
  const vite = Bun.spawn(
96
- ["bunx", "vite", "--clearScreen", "false", "--port", String(port), "--strictPort"],
196
+ [
197
+ "bunx",
198
+ "vite",
199
+ "--clearScreen",
200
+ "false",
201
+ "--host",
202
+ "127.0.0.1",
203
+ "--port",
204
+ String(port),
205
+ "--strictPort",
206
+ ],
97
207
  {
98
208
  cwd: projectDir,
99
- stdio: ["ignore", "inherit", "inherit"],
209
+ stdio: reporter.childStdio,
100
210
  },
101
211
  );
102
- await waitForUrl(devUrl, 15_000);
212
+ try {
213
+ await waitForUrl(devUrl, 15_000);
214
+ vitePhase?.ok(devUrl);
215
+ } catch (err) {
216
+ vitePhase?.fail(err instanceof Error ? err.message : String(err));
217
+ vite.kill();
218
+ throw err;
219
+ }
103
220
 
104
221
  // --- launch the app ---
105
- console.log(`[mirin dev] launching ${appName}…`);
222
+ const launchPhase = session?.phase("launch");
223
+ reporter.info(`[mirin dev] launching ${appName}…`);
106
224
  const appProc = Bun.spawn([exe], {
107
225
  cwd: projectDir,
108
226
  env: {
109
227
  ...process.env,
110
228
  MIRIN_CORE: IS_WINDOWS
111
229
  ? join(app, "mirin_core.dll")
112
- : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
230
+ : IS_LINUX
231
+ ? join(app, "libmirin_core.so")
232
+ : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
233
+ // Linux: the flat app dir holds libcef.so + libmirin_core.so; put it on the
234
+ // loader path so the host's dlopen and libmirin_core's NEEDED libcef.so (and
235
+ // the spawned mirin-helper) all resolve. (macOS uses the framework loader;
236
+ // Windows the exe-dir search — neither needs this.)
237
+ ...(IS_LINUX
238
+ ? {
239
+ LD_LIBRARY_PATH: `${app}${process.env.LD_LIBRARY_PATH ? ":" + process.env.LD_LIBRARY_PATH : ""}`,
240
+ }
241
+ : {}),
113
242
  MIRIN_WORKER: workerJs,
114
243
  MIRIN_DEV_URL: devUrl,
115
- MIRIN_MANIFEST_JSON: JSON.stringify({ windows: config.windows }),
244
+ MIRIN_MANIFEST_JSON: JSON.stringify({
245
+ id: bundleId,
246
+ windows: config.windows,
247
+ devtools: config.devtools,
248
+ }),
116
249
  // dev: true → enables inspect-element AND gives this run its own `-dev`
117
250
  // CEF cache dir, so `mirin dev` can run alongside the installed app.
118
- MIRIN_CONFIG_JSON: JSON.stringify({ dev: true }),
251
+ // `icon_path` (Linux) → the window's `_NET_WM_ICON` (cosmic dock/taskbar).
252
+ MIRIN_CONFIG_JSON: JSON.stringify({
253
+ dev: true,
254
+ ...(linuxIconPng ? { icon_path: linuxIconPng } : {}),
255
+ }),
119
256
  MIRIN_SIDECAR_DIR: sidecarsDir,
120
257
  MIRIN_WORKERS_DIR: workersDir,
258
+ // CEF's DevTools-protocol port (screenshots, snapshots, eval, input).
259
+ // Loopback only, and only ever set for a dev run (docs/agent-devtools.md).
260
+ MIRIN_CDP_PORT: String(cdpPort),
261
+ ...(session?.env() ?? {}),
121
262
  },
122
- stdio: ["ignore", "inherit", "inherit"],
263
+ stdio: reporter.childStdio,
123
264
  });
265
+ session?.setPid(appProc.pid);
266
+ launchPhase?.ok();
267
+ reporter.event("launch", {
268
+ pid: appProc.pid,
269
+ devUrl,
270
+ cdpPort,
271
+ session: session?.paths.dir ?? null,
272
+ });
273
+ if (session) {
274
+ reporter.info(`[mirin dev] dev session: ${session.paths.dir}`);
275
+ // The app publishes its inspector endpoint once the Worker binds it; report it
276
+ // when it lands, without holding up the dev loop if it never does.
277
+ void session.waitForInspector().then((endpoint) => {
278
+ if (endpoint === undefined) return;
279
+ reporter.info(
280
+ `[mirin dev] inspector: http://127.0.0.1:${endpoint.port} — token in inspector.json`,
281
+ );
282
+ reporter.event("inspector", { url: `http://127.0.0.1:${endpoint.port}` });
283
+ });
284
+ }
124
285
 
125
286
  const cleanup = () => {
126
287
  vite.kill();
@@ -129,8 +290,26 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
129
290
  process.on("SIGINT", cleanup);
130
291
  process.on("SIGTERM", cleanup);
131
292
 
293
+ if (options?.onLaunched !== undefined) {
294
+ try {
295
+ await options.onLaunched({
296
+ ...(session !== undefined ? { session } : {}),
297
+ devUrl,
298
+ cdpPort,
299
+ pid: appProc.pid,
300
+ stop: cleanup,
301
+ });
302
+ } catch (err) {
303
+ console.error(`[mirin] launch hook failed: ${err instanceof Error ? err.message : err}`);
304
+ } finally {
305
+ // A hooked run is a one-shot: stop the app whether the hook succeeded or not.
306
+ cleanup();
307
+ }
308
+ }
309
+
132
310
  const code = await appProc.exited;
133
311
  vite.kill();
312
+ session?.finish(code, appProc.signalCode ?? undefined);
134
313
  return code;
135
314
  }
136
315
 
package/src/dmg.ts CHANGED
@@ -15,10 +15,10 @@
15
15
  * stapled so a fresh download opens without a Gatekeeper prompt.
16
16
  */
17
17
 
18
- import { $ } from "bun";
19
- import { cpSync, mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
20
- import { join, basename, isAbsolute } from "node:path";
18
+ import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
21
19
  import { tmpdir } from "node:os";
20
+ import { basename, isAbsolute, join } from "node:path";
21
+ import { $ } from "bun";
22
22
 
23
23
  export interface DmgOptions {
24
24
  volumeName?: string;
@@ -148,12 +148,20 @@ export async function buildDmg(input: BuildDmgInput): Promise<string> {
148
148
  let built = false;
149
149
  if (wantsLayout(options)) {
150
150
  try {
151
- await buildLaidOutDmg({ staging, vol, format, dmgPath, options, projectDir, appFile: basename(app) });
151
+ await buildLaidOutDmg({
152
+ staging,
153
+ vol,
154
+ format,
155
+ dmgPath,
156
+ options,
157
+ projectDir,
158
+ appFile: basename(app),
159
+ });
152
160
  built = true;
153
161
  } catch (e) {
154
162
  console.warn(
155
163
  `[mirin release] DMG layout failed (${e instanceof Error ? e.message : e}); ` +
156
- `falling back to a plain DMG.`,
164
+ "falling back to a plain DMG.",
157
165
  );
158
166
  rmSync(dmgPath, { force: true });
159
167
  }
@@ -190,9 +198,14 @@ async function buildLaidOutDmg(args: {
190
198
  const { staging, vol, format, dmgPath, options, projectDir, appFile } = args;
191
199
  const win = options.windowSize ?? { width: 640, height: 400 };
192
200
  const iconSize = options.iconSize ?? 128;
193
- const appPos = options.appPosition ?? { x: Math.round(win.width * 0.25), y: Math.round(win.height * 0.5) };
194
- const appsPos =
195
- options.applicationsPosition ?? { x: Math.round(win.width * 0.75), y: Math.round(win.height * 0.5) };
201
+ const appPos = options.appPosition ?? {
202
+ x: Math.round(win.width * 0.25),
203
+ y: Math.round(win.height * 0.5),
204
+ };
205
+ const appsPos = options.applicationsPosition ?? {
206
+ x: Math.round(win.width * 0.75),
207
+ y: Math.round(win.height * 0.5),
208
+ };
196
209
 
197
210
  // Stage a background image (if any) under a hidden folder Finder can reference.
198
211
  let bgFile: string | undefined;
@@ -214,8 +227,7 @@ async function buildLaidOutDmg(args: {
214
227
  await $`hdiutil create -volname ${vol} -srcfolder ${staging} -fs HFS+ -format UDRW -ov ${rw}`.quiet();
215
228
 
216
229
  // Attach without auto-opening a Finder window; parse the real mountpoint.
217
- const attach =
218
- await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
230
+ const attach = await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
219
231
  const mount = attach
220
232
  .split("\n")
221
233
  .map((l) => l.trim())
package/src/doctor.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `mirin doctor` — check the project and environment before anything is built,
3
+ * including the previous session's outcome (docs/agent-devtools.md).
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { createServer } from "node:net";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ readCurrentSession,
12
+ readEventsFile,
13
+ readSessionInfo,
14
+ sessionPaths,
15
+ } from "mirinjs/devtools/session";
16
+ import { isInRepo, vendoredCef } from "./artifacts.ts";
17
+ import { createReporter } from "./shared/report.ts";
18
+
19
+ export type CheckStatus = "ok" | "warn" | "fail";
20
+
21
+ export interface DoctorCheck {
22
+ name: string;
23
+ status: CheckStatus;
24
+ detail: string;
25
+ fix?: string;
26
+ }
27
+
28
+ export interface DoctorReport {
29
+ ok: boolean;
30
+ projectDir: string;
31
+ platform: string;
32
+ checks: DoctorCheck[];
33
+ }
34
+
35
+ const SUPPORTED = new Set(["darwin-arm64", "win32-x64", "win32-arm64", "linux-x64", "linux-arm64"]);
36
+
37
+ function ok(name: string, detail: string): DoctorCheck {
38
+ return { name, status: "ok", detail };
39
+ }
40
+ function warn(name: string, detail: string, fix?: string): DoctorCheck {
41
+ return { name, status: "warn", detail, ...(fix !== undefined ? { fix } : {}) };
42
+ }
43
+ function fail(name: string, detail: string, fix?: string): DoctorCheck {
44
+ return { name, status: "fail", detail, ...(fix !== undefined ? { fix } : {}) };
45
+ }
46
+
47
+ function portFree(port: number): Promise<boolean> {
48
+ return new Promise((resolve) => {
49
+ const server = createServer();
50
+ server.once("error", () => resolve(false));
51
+ server.once("listening", () => server.close(() => resolve(true)));
52
+ server.listen(port, "127.0.0.1");
53
+ });
54
+ }
55
+
56
+ function checkPlatform(): DoctorCheck {
57
+ const target = `${process.platform}-${process.arch}`;
58
+ return SUPPORTED.has(target)
59
+ ? ok("platform", `${target}, bun ${Bun.version}`)
60
+ : fail(
61
+ "platform",
62
+ `${target} is not a supported target`,
63
+ `supported: ${[...SUPPORTED].join(", ")}`,
64
+ );
65
+ }
66
+
67
+ /** Load `mirin.config.ts` and check the fields the CLI and host both require. */
68
+ async function checkConfig(projectDir: string): Promise<DoctorCheck[]> {
69
+ const path = join(projectDir, "mirin.config.ts");
70
+ if (!existsSync(path)) {
71
+ return [fail("config", "mirin.config.ts not found", "run `mirin init` or check the directory")];
72
+ }
73
+
74
+ let config: Record<string, unknown>;
75
+ try {
76
+ const loaded: unknown = (await import(path)).default;
77
+ if (typeof loaded !== "object" || loaded === null) {
78
+ return [fail("config", "mirin.config.ts has no default export object")];
79
+ }
80
+ config = loaded as Record<string, unknown>;
81
+ } catch (err) {
82
+ return [
83
+ fail("config", `mirin.config.ts failed to load: ${err instanceof Error ? err.message : err}`),
84
+ ];
85
+ }
86
+
87
+ const checks: DoctorCheck[] = [];
88
+ const missing = ["id", "name", "main", "windows"].filter((key) => config[key] == null);
89
+ checks.push(
90
+ missing.length === 0
91
+ ? ok("config", `${String(config.name)} (${String(config.id)})`)
92
+ : fail("config", `missing required field(s): ${missing.join(", ")}`),
93
+ );
94
+
95
+ const main = typeof config.main === "string" ? config.main : "main/main.ts";
96
+ checks.push(
97
+ existsSync(join(projectDir, main))
98
+ ? ok("main entry", main)
99
+ : fail("main entry", `${main} does not exist`, "point `main` at your main-process entry"),
100
+ );
101
+
102
+ const windows =
103
+ typeof config.windows === "object" && config.windows !== null ? config.windows : {};
104
+ const names = Object.keys(windows);
105
+ checks.push(
106
+ names.length > 0
107
+ ? ok("windows", names.join(", "))
108
+ : warn("windows", "no windows declared", "the app will start with nothing visible"),
109
+ );
110
+
111
+ const devtools = config.devtools;
112
+ if (typeof devtools === "object" && devtools !== null && "production" in devtools) {
113
+ const enabled = (devtools as { production?: unknown }).production === true;
114
+ checks.push(
115
+ enabled
116
+ ? warn(
117
+ "devtools",
118
+ "devtools.production is true — packaged builds will expose the inspector",
119
+ "remove it before shipping: the inspector can evaluate code in your app",
120
+ )
121
+ : ok("devtools", "production access disabled"),
122
+ );
123
+ }
124
+ return checks;
125
+ }
126
+
127
+ function checkUi(projectDir: string): DoctorCheck[] {
128
+ const checks: DoctorCheck[] = [];
129
+ checks.push(
130
+ existsSync(join(projectDir, "index.html"))
131
+ ? ok("ui entry", "index.html")
132
+ : warn("ui entry", "no index.html at the project root", "Vite needs an HTML entry"),
133
+ );
134
+ const viteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mts"].find((name) =>
135
+ existsSync(join(projectDir, name)),
136
+ );
137
+ checks.push(
138
+ viteConfig !== undefined
139
+ ? ok("vite config", viteConfig)
140
+ : warn("vite config", "none found", "Vite will run with defaults"),
141
+ );
142
+ return checks;
143
+ }
144
+
145
+ function checkCef(): DoctorCheck {
146
+ if (isInRepo()) {
147
+ // Resolved from the CLI's location, not the cwd, which is usually an example app.
148
+ const vendored = vendoredCef();
149
+ return vendored.present
150
+ ? ok("cef", "vendor/cef is present")
151
+ : warn("cef", `${vendored.path} is missing or incomplete`, "run `bun scripts/fetch-cef.ts`");
152
+ }
153
+ const cache = join(homedir(), ".mirinjs", "cef");
154
+ return existsSync(cache)
155
+ ? ok("cef", `cached at ${cache}`)
156
+ : warn("cef", "no cached CEF runtime", "the first `mirin dev` downloads it (a few hundred MB)");
157
+ }
158
+
159
+ async function checkPorts(): Promise<DoctorCheck[]> {
160
+ const [vite, cdp] = await Promise.all([portFree(5173), portFree(9222)]);
161
+ return [
162
+ vite
163
+ ? ok("vite port", "5173 is free")
164
+ : warn("vite port", "5173 is in use", "mirin dev probes upward, so this is not fatal"),
165
+ cdp
166
+ ? ok("devtools port", "9222 is free")
167
+ : warn("devtools port", "9222 is in use", "mirin dev probes upward, so this is not fatal"),
168
+ ];
169
+ }
170
+
171
+ function checkLastSession(projectDir: string): DoctorCheck {
172
+ const dir = readCurrentSession(projectDir);
173
+ if (dir === undefined) return ok("last session", "none recorded yet");
174
+
175
+ const paths = sessionPaths(dir);
176
+ const info = readSessionInfo(paths);
177
+ const errors = readEventsFile(paths.events).filter((event) => event.level === "error");
178
+ const lastPhase = info?.phases.at(-1);
179
+ const phase =
180
+ lastPhase !== undefined ? `${lastPhase.name}=${lastPhase.status}` : "no phases recorded";
181
+
182
+ if (errors.length === 0) return ok("last session", `${phase}, no errors — ${dir}`);
183
+ const first = errors[0];
184
+ return warn(
185
+ "last session",
186
+ `${phase}, ${errors.length} error(s) — first: ${first?.msg ?? ""}`,
187
+ `full stream: ${paths.events}`,
188
+ );
189
+ }
190
+
191
+ function renderReport(report: DoctorReport): void {
192
+ const glyph: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗" };
193
+ console.log(`\nmirin doctor — ${report.projectDir}\n`);
194
+ for (const check of report.checks) {
195
+ console.log(` ${glyph[check.status]} ${check.name.padEnd(14)} ${check.detail}`);
196
+ if (check.fix !== undefined) console.log(`${" ".repeat(19)}→ ${check.fix}`);
197
+ }
198
+ const failures = report.checks.filter((check) => check.status === "fail").length;
199
+ const warnings = report.checks.filter((check) => check.status === "warn").length;
200
+ console.log(
201
+ `\n${report.ok ? "✓ ready" : `✗ ${failures} blocking problem(s)`}${warnings > 0 ? `, ${warnings} warning(s)` : ""}\n`,
202
+ );
203
+ }
204
+
205
+ /** Run the preflight. Exit code 0 unless a check failed outright. */
206
+ export async function doctor(
207
+ projectDir = process.cwd(),
208
+ options: { json?: boolean } = {},
209
+ ): Promise<number> {
210
+ const reporter = createReporter(options.json === true);
211
+ const checks: DoctorCheck[] = [
212
+ checkPlatform(),
213
+ ...(await checkConfig(projectDir)),
214
+ ...checkUi(projectDir),
215
+ checkCef(),
216
+ ...(await checkPorts()),
217
+ checkLastSession(projectDir),
218
+ ];
219
+ const report: DoctorReport = {
220
+ ok: !checks.some((check) => check.status === "fail"),
221
+ projectDir,
222
+ platform: `${process.platform}-${process.arch}`,
223
+ checks,
224
+ };
225
+ reporter.finish({ ...report }, () => renderReport(report));
226
+ return report.ok ? 0 : 1;
227
+ }