@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.
- package/package.json +8 -5
- package/src/artifacts.ts +165 -26
- package/src/build.ts +236 -43
- package/src/bundle/linux/app.ts +275 -0
- package/src/bundle/linux/index.ts +1 -0
- package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
- package/src/bundle/macos/index.ts +1 -0
- package/src/bundle/shared/cef-locales.ts +91 -0
- package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
- package/src/bundle/windows/index.ts +1 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +214 -35
- package/src/dmg.ts +22 -10
- package/src/doctor.ts +227 -0
- package/src/extras.ts +52 -13
- package/src/icons/macos/appearance.ts +96 -0
- package/src/icons/macos/index.ts +1 -0
- package/src/icons/sources.ts +25 -0
- package/src/icons/windows/icon.ts +255 -0
- package/src/icons/windows/index.ts +1 -0
- package/src/index.ts +109 -7
- package/src/installer-inno.ts +141 -0
- package/src/installer-win.ts +17 -11
- package/src/package/linux/index.ts +2 -0
- package/src/package/linux/lib/defaults.ts +53 -0
- package/src/package/linux/lib/tools.ts +45 -0
- package/src/package/linux/lib/validation.ts +71 -0
- package/src/package/linux/package.ts +293 -0
- package/src/package/linux/types.ts +30 -0
- package/src/release/installers.ts +148 -0
- package/src/release/previous.ts +105 -0
- package/src/release.ts +166 -116
- package/src/shared/driver.ts +240 -0
- package/src/shared/inspector.ts +64 -0
- package/src/shared/report.ts +69 -0
- package/src/shared/scenario.ts +58 -0
- package/src/shared/session.ts +153 -0
- package/src/skill.ts +51 -0
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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:
|
|
137
|
+
icon: iconSrc,
|
|
75
138
|
})
|
|
76
|
-
:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
94
|
-
|
|
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
|
-
[
|
|
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:
|
|
209
|
+
stdio: reporter.childStdio,
|
|
100
210
|
},
|
|
101
211
|
);
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
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 ?? {
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|