@mirinjs/cli 0.1.0-alpha.18 → 0.1.0-alpha.19
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 -8
- package/src/artifacts.ts +24 -9
- package/src/build.ts +2 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +117 -14
- package/src/doctor.ts +227 -0
- package/src/extras.ts +7 -5
- package/src/index.ts +61 -3
- 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/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.19",
|
|
4
4
|
"description": "CLI for mirin apps: dev, build, init.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,15 +27,15 @@
|
|
|
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.19",
|
|
31
|
+
"mirinjs": "0.1.0-alpha.19"
|
|
32
32
|
},
|
|
33
33
|
"optionalDependencies": {
|
|
34
|
-
"@mirinjs/darwin-arm64": "0.1.0-alpha.
|
|
35
|
-
"@mirinjs/win32-x64": "0.1.0-alpha.
|
|
36
|
-
"@mirinjs/win32-arm64": "0.1.0-alpha.
|
|
37
|
-
"@mirinjs/linux-arm64": "0.1.0-alpha.
|
|
38
|
-
"@mirinjs/linux-x64": "0.1.0-alpha.
|
|
34
|
+
"@mirinjs/darwin-arm64": "0.1.0-alpha.19",
|
|
35
|
+
"@mirinjs/win32-x64": "0.1.0-alpha.19",
|
|
36
|
+
"@mirinjs/win32-arm64": "0.1.0-alpha.19",
|
|
37
|
+
"@mirinjs/linux-arm64": "0.1.0-alpha.19",
|
|
38
|
+
"@mirinjs/linux-x64": "0.1.0-alpha.19"
|
|
39
39
|
},
|
|
40
40
|
"publishConfig": {
|
|
41
41
|
"access": "public"
|
package/src/artifacts.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { homedir, tmpdir } from "node:os";
|
|
26
26
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
27
27
|
import { $ } from "bun";
|
|
28
|
+
import { createReporter, type Reporter } from "./shared/report.ts";
|
|
28
29
|
|
|
29
30
|
const REPO_SLUG = "Netko-Labs/mirin";
|
|
30
31
|
|
|
@@ -49,15 +50,29 @@ export function isInRepo(): boolean {
|
|
|
49
50
|
return IN_REPO;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
/** The in-repo CEF distribution. `present` checks the framework marker, not just
|
|
54
|
+
* the directory: an interrupted `fetch-cef.ts` leaves an empty one behind. */
|
|
55
|
+
export function vendoredCef(): { path: string; present: boolean } {
|
|
56
|
+
const path = join(REPO_ROOT, "vendor", "cef");
|
|
57
|
+
return { path, present: existsSync(join(path, cefMarker())) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function resolveArtifacts(opts: {
|
|
61
|
+
release: boolean;
|
|
62
|
+
/** Where build chatter goes. Defaults to human mode, which prints it. */
|
|
63
|
+
reporter?: Reporter;
|
|
64
|
+
}): Promise<Artifacts> {
|
|
53
65
|
assertSupportedPlatform();
|
|
54
|
-
const
|
|
66
|
+
const reporter = opts.reporter ?? createReporter(false);
|
|
67
|
+
const cefPath = await ensureCef(reporter);
|
|
55
68
|
|
|
56
69
|
if (IN_REPO) {
|
|
57
70
|
const profile = opts.release ? "release" : "debug";
|
|
58
|
-
|
|
71
|
+
reporter.info(`[mirin] building native core + helpers (${profile})…`);
|
|
59
72
|
const flags = opts.release ? ["--release"] : [];
|
|
60
|
-
await
|
|
73
|
+
await reporter.build(
|
|
74
|
+
$`cargo build -p mirin-codec -p mirin-core -p mirin-helper ${flags}`.cwd(REPO_ROOT),
|
|
75
|
+
);
|
|
61
76
|
const target = join(REPO_ROOT, "target", profile);
|
|
62
77
|
return {
|
|
63
78
|
coreDylib: join(target, coreFileName()),
|
|
@@ -142,14 +157,14 @@ function cefMarker(): string {
|
|
|
142
157
|
return "Chromium Embedded Framework.framework";
|
|
143
158
|
}
|
|
144
159
|
|
|
145
|
-
async function ensureCef(): Promise<string> {
|
|
160
|
+
async function ensureCef(reporter: Reporter): Promise<string> {
|
|
146
161
|
if (IN_REPO) {
|
|
147
|
-
const vendor =
|
|
148
|
-
if (
|
|
162
|
+
const vendor = vendoredCef();
|
|
163
|
+
if (vendor.present) return vendor.path;
|
|
149
164
|
console.error(
|
|
150
165
|
"[mirin] vendor/cef missing — run `bun scripts/fetch-cef.ts` in the monorepo first.",
|
|
151
166
|
);
|
|
152
|
-
return vendor;
|
|
167
|
+
return vendor.path;
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
const version = cliVersion();
|
|
@@ -160,7 +175,7 @@ async function ensureCef(): Promise<string> {
|
|
|
160
175
|
mkdirSync(cacheRoot, { recursive: true });
|
|
161
176
|
const asset = `cef-${platformTag()}.tar.gz`;
|
|
162
177
|
const url = `https://github.com/${REPO_SLUG}/releases/download/v${version}/${asset}`;
|
|
163
|
-
|
|
178
|
+
reporter.info(`[mirin] downloading CEF for ${platformTag()} (one-time, ~hundreds of MB)…`);
|
|
164
179
|
|
|
165
180
|
// Download with curl, not `Bun.write(file, await fetch(url))`: the latter
|
|
166
181
|
// pins a core at 100% CPU and never completes on large gzip responses
|
package/src/build.ts
CHANGED
|
@@ -251,6 +251,8 @@ export async function build(
|
|
|
251
251
|
id: bundleId,
|
|
252
252
|
windows: config.windows,
|
|
253
253
|
singleInstance: config.singleInstance,
|
|
254
|
+
// Lets a diagnostic build opt in via `devtools.production`; otherwise fully off.
|
|
255
|
+
devtools: config.devtools,
|
|
254
256
|
}),
|
|
255
257
|
versionJson,
|
|
256
258
|
workers: extraWorkers,
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mirin check` — boot the app once, capture a screenshot and accessibility
|
|
3
|
+
* snapshot, collect errors, exit non-zero on failure (docs/agent-devtools.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CheckScenario } from "mirinjs/check";
|
|
7
|
+
import type { DevEvent } from "mirinjs/devtools/session";
|
|
8
|
+
import { readEventsFile, sessionPaths } from "mirinjs/devtools/session";
|
|
9
|
+
import { dev } from "./dev.ts";
|
|
10
|
+
import type { ScenarioOutcome } from "./shared/driver.ts";
|
|
11
|
+
import { InspectorClient } from "./shared/inspector.ts";
|
|
12
|
+
import { createReporter, type Reporter } from "./shared/report.ts";
|
|
13
|
+
import { loadScenario, runScenario } from "./shared/scenario.ts";
|
|
14
|
+
|
|
15
|
+
/** How long to wait for the app to show a window before giving up. */
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 45_000;
|
|
17
|
+
|
|
18
|
+
/** Quiet time after the first window appears, so the capture shows painted UI
|
|
19
|
+
* rather than an empty frame. */
|
|
20
|
+
const DEFAULT_SETTLE_MS = 1_000;
|
|
21
|
+
|
|
22
|
+
export interface CheckOptions {
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
settleMs?: number;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
/** Path to a scenario module (`mirin check --scenario ./check.ts`). */
|
|
27
|
+
scenario?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CheckReport {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
reason?: string;
|
|
33
|
+
session: string | null;
|
|
34
|
+
inspector: string | null;
|
|
35
|
+
windows: { id: number; name?: string; url?: string; title?: string }[];
|
|
36
|
+
/** Path to the captured screenshot. */
|
|
37
|
+
screenshot: string | null;
|
|
38
|
+
/** Accessibility snapshot of the first window. */
|
|
39
|
+
snapshot: string | null;
|
|
40
|
+
/** Slices the app published with `devtools.expose`, read at the end of the run. */
|
|
41
|
+
exposed: Record<string, unknown>;
|
|
42
|
+
errors: DevEvent[];
|
|
43
|
+
eventCount: number;
|
|
44
|
+
durationMs: number;
|
|
45
|
+
/** Present when `--scenario` ran. */
|
|
46
|
+
scenario?: { file: string } & ScenarioOutcome;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function record(value: unknown): Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
51
|
+
? (value as Record<string, unknown>)
|
|
52
|
+
: {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function windowsOf(state: unknown): CheckReport["windows"] {
|
|
56
|
+
const list = record(state).windows;
|
|
57
|
+
if (!Array.isArray(list)) return [];
|
|
58
|
+
return list.flatMap((entry) => {
|
|
59
|
+
const win = record(entry);
|
|
60
|
+
const id = win.id;
|
|
61
|
+
if (typeof id !== "number") return [];
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
id,
|
|
65
|
+
...(typeof win.name === "string" ? { name: win.name } : {}),
|
|
66
|
+
...(typeof win.url === "string" ? { url: win.url } : {}),
|
|
67
|
+
...(typeof win.title === "string" ? { title: win.title } : {}),
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Poll `/state` until a window exists, or the deadline passes. */
|
|
74
|
+
async function waitForWindow(
|
|
75
|
+
client: InspectorClient,
|
|
76
|
+
deadline: number,
|
|
77
|
+
): Promise<CheckReport["windows"]> {
|
|
78
|
+
let last: CheckReport["windows"] = [];
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
try {
|
|
81
|
+
last = windowsOf(await client.get("/state"));
|
|
82
|
+
if (last.length > 0) return last;
|
|
83
|
+
} catch {
|
|
84
|
+
// The inspector may still be binding; keep trying until the deadline.
|
|
85
|
+
}
|
|
86
|
+
await Bun.sleep(250);
|
|
87
|
+
}
|
|
88
|
+
return last;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function capture(client: InspectorClient, report: CheckReport): Promise<void> {
|
|
92
|
+
try {
|
|
93
|
+
const shot = record(await client.get("/screenshot"));
|
|
94
|
+
if (typeof shot.path === "string") report.screenshot = shot.path;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
report.errors.push(syntheticError(`screenshot failed: ${message(err)}`));
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const snap = record(await client.get("/snapshot"));
|
|
100
|
+
if (typeof snap.snapshot === "string") report.snapshot = snap.snapshot;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
report.errors.push(syntheticError(`snapshot failed: ${message(err)}`));
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
report.exposed = record(record(await client.get("/state")).exposed);
|
|
106
|
+
} catch {
|
|
107
|
+
// The app publishing nothing is the common case; not worth an error.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function message(err: unknown): string {
|
|
112
|
+
return err instanceof Error ? err.message : String(err);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A failure of the check itself, shaped like a stream event so one list suffices. */
|
|
116
|
+
function syntheticError(text: string): DevEvent {
|
|
117
|
+
return { seq: 0, ts: Date.now(), src: "main", level: "error", type: "check.error", msg: text };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderReport(report: CheckReport): void {
|
|
121
|
+
const mark = report.ok ? "✓" : "✗";
|
|
122
|
+
console.log(`\n${mark} mirin check ${report.ok ? "passed" : "failed"} in ${report.durationMs}ms`);
|
|
123
|
+
if (report.reason !== undefined) console.log(` reason: ${report.reason}`);
|
|
124
|
+
|
|
125
|
+
if (report.windows.length > 0) {
|
|
126
|
+
console.log(` windows: ${report.windows.length}`);
|
|
127
|
+
for (const win of report.windows) {
|
|
128
|
+
const label = [win.name, win.title].filter(Boolean).join(" / ");
|
|
129
|
+
console.log(
|
|
130
|
+
` #${win.id}${label.length > 0 ? ` ${label}` : ""} ${win.url ?? ""}`.trimEnd(),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (report.scenario !== undefined) {
|
|
135
|
+
const { file, steps, screenshots, failure } = report.scenario;
|
|
136
|
+
console.log(` scenario: ${file}`);
|
|
137
|
+
for (const step of steps) {
|
|
138
|
+
const mark = step.ok ? "✓" : "✗";
|
|
139
|
+
console.log(` ${mark} ${step.name}${step.ms >= 0 ? ` (${step.ms}ms)` : ""}`);
|
|
140
|
+
}
|
|
141
|
+
if (failure !== undefined) console.log(` → ${failure.message}`);
|
|
142
|
+
for (const shot of screenshots) console.log(` shot: ${shot}`);
|
|
143
|
+
}
|
|
144
|
+
const exposedKeys = Object.keys(report.exposed);
|
|
145
|
+
if (exposedKeys.length > 0) {
|
|
146
|
+
console.log(` app state: ${JSON.stringify(report.exposed).slice(0, 400)}`);
|
|
147
|
+
}
|
|
148
|
+
if (report.screenshot !== null) console.log(` screenshot: ${report.screenshot}`);
|
|
149
|
+
if (report.session !== null) console.log(` session: ${report.session}`);
|
|
150
|
+
console.log(` events: ${report.eventCount}, errors: ${report.errors.length}`);
|
|
151
|
+
|
|
152
|
+
for (const error of report.errors.slice(0, 10)) {
|
|
153
|
+
console.log(`\n [${error.src}] ${error.type}: ${error.msg}`);
|
|
154
|
+
const stack = record(error.data).stack;
|
|
155
|
+
if (Array.isArray(stack)) {
|
|
156
|
+
for (const frame of stack.slice(0, 4)) console.log(` at ${String(frame)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (report.errors.length > 10) {
|
|
160
|
+
console.log(`\n … ${report.errors.length - 10} more errors (see the session's events.jsonl)`);
|
|
161
|
+
}
|
|
162
|
+
if (report.snapshot !== null) {
|
|
163
|
+
console.log(`\n ui snapshot:\n${report.snapshot.replace(/^/gm, " ")}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Run the check. Returns the process exit code: 0 when the app came up clean. */
|
|
168
|
+
export async function check(
|
|
169
|
+
projectDir = process.cwd(),
|
|
170
|
+
options: CheckOptions = {},
|
|
171
|
+
): Promise<number> {
|
|
172
|
+
const reporter: Reporter = createReporter(options.json === true);
|
|
173
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
174
|
+
const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
|
|
177
|
+
const report: CheckReport = {
|
|
178
|
+
ok: false,
|
|
179
|
+
session: null,
|
|
180
|
+
inspector: null,
|
|
181
|
+
windows: [],
|
|
182
|
+
screenshot: null,
|
|
183
|
+
snapshot: null,
|
|
184
|
+
exposed: {},
|
|
185
|
+
errors: [],
|
|
186
|
+
eventCount: 0,
|
|
187
|
+
durationMs: 0,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
reporter.event("start", { projectDir, timeoutMs });
|
|
191
|
+
|
|
192
|
+
// Load the scenario before anything is built: a typo in the path should cost a
|
|
193
|
+
// second, not a cold compile.
|
|
194
|
+
let scenario: CheckScenario | undefined;
|
|
195
|
+
if (options.scenario !== undefined) {
|
|
196
|
+
try {
|
|
197
|
+
scenario = await loadScenario(projectDir, options.scenario);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
report.reason = message(err);
|
|
200
|
+
report.durationMs = Date.now() - startedAt;
|
|
201
|
+
reporter.finish({ ...report }, () => renderReport(report));
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await dev(projectDir, {
|
|
207
|
+
// Inherit the output mode: in JSON mode `mirin dev`'s prose would otherwise
|
|
208
|
+
// interleave with the report and break parsing.
|
|
209
|
+
json: options.json === true,
|
|
210
|
+
async onLaunched(context) {
|
|
211
|
+
const deadline = Date.now() + timeoutMs;
|
|
212
|
+
const session = context.session;
|
|
213
|
+
if (session === undefined) {
|
|
214
|
+
report.reason = "no dev session was created — devtools artifacts are unavailable";
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
report.session = session.paths.dir;
|
|
218
|
+
|
|
219
|
+
reporter.event("await-inspector");
|
|
220
|
+
const endpoint = await session.waitForInspector(Math.max(1_000, deadline - Date.now()));
|
|
221
|
+
if (endpoint === undefined) {
|
|
222
|
+
report.reason = "the app never published an inspector endpoint";
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
report.inspector = `http://127.0.0.1:${endpoint.port}`;
|
|
226
|
+
const client = new InspectorClient(endpoint);
|
|
227
|
+
|
|
228
|
+
reporter.event("await-window");
|
|
229
|
+
report.windows = await waitForWindow(client, deadline);
|
|
230
|
+
if (report.windows.length === 0) {
|
|
231
|
+
report.reason = `no window appeared within ${timeoutMs}ms`;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Let the first paint land, or the capture shows an empty frame and tree.
|
|
236
|
+
await Bun.sleep(settleMs);
|
|
237
|
+
|
|
238
|
+
if (scenario !== undefined && options.scenario !== undefined) {
|
|
239
|
+
reporter.event("scenario", { file: options.scenario });
|
|
240
|
+
const outcome = await runScenario(client, scenario, (name) =>
|
|
241
|
+
reporter.event("step", { name }),
|
|
242
|
+
);
|
|
243
|
+
report.scenario = { file: options.scenario, ...outcome };
|
|
244
|
+
if (outcome.failure !== undefined) {
|
|
245
|
+
report.reason = `scenario failed at "${outcome.failure.step}": ${outcome.failure.message}`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Capture after the scenario, so artifacts show the state the run ended in.
|
|
250
|
+
reporter.event("capture", { windows: report.windows.length });
|
|
251
|
+
await capture(client, report);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Read the stream from disk rather than the inspector: by now the app has exited,
|
|
256
|
+
// and the file is the complete record of the run.
|
|
257
|
+
if (report.session !== null) {
|
|
258
|
+
const events = readEventsFile(sessionPaths(report.session).events);
|
|
259
|
+
report.eventCount = events.length;
|
|
260
|
+
report.errors.push(...events.filter((event) => event.level === "error"));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
report.durationMs = Date.now() - startedAt;
|
|
264
|
+
report.ok = report.reason === undefined && report.errors.length === 0;
|
|
265
|
+
if (!report.ok && report.reason === undefined) {
|
|
266
|
+
report.reason = `${report.errors.length} error-level event(s) during startup`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
reporter.finish({ ...report }, () => renderReport(report));
|
|
270
|
+
return report.ok ? 0 : 1;
|
|
271
|
+
}
|
package/src/dev.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
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
14
|
import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
@@ -27,13 +30,37 @@ const IS_WINDOWS = process.platform === "win32";
|
|
|
27
30
|
const IS_LINUX = process.platform === "linux";
|
|
28
31
|
|
|
29
32
|
import { compileWorkers, normalizeSidecars } from "./extras.ts";
|
|
33
|
+
import { createReporter } from "./shared/report.ts";
|
|
34
|
+
import { DevSession } from "./shared/session.ts";
|
|
30
35
|
import { sweepBuildTemps } from "./temps.ts";
|
|
31
36
|
|
|
32
37
|
/** Vite's default port. `mirin dev` probes upward from here for a free one, so a
|
|
33
38
|
* second dev session (or anything already on 5173) doesn't collide. */
|
|
34
39
|
const DEV_PORT_BASE = 5173;
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
/** Where the CEF DevTools-protocol port search starts. Well clear of Vite's range
|
|
42
|
+
* so concurrent dev sessions of different apps don't fight over either. */
|
|
43
|
+
const CDP_PORT_BASE = 9222;
|
|
44
|
+
|
|
45
|
+
export interface DevLaunchContext {
|
|
46
|
+
session?: DevSession;
|
|
47
|
+
devUrl: string;
|
|
48
|
+
cdpPort: number;
|
|
49
|
+
pid: number;
|
|
50
|
+
/** Stop the app and the Vite server. */
|
|
51
|
+
stop(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DevOptions {
|
|
55
|
+
/** Emit newline-delimited JSON progress instead of prose. */
|
|
56
|
+
json?: boolean;
|
|
57
|
+
/** Called once the app is running. The app is always stopped after the hook
|
|
58
|
+
* settles, whether it threw or not. */
|
|
59
|
+
onLaunched?(context: DevLaunchContext): Promise<void> | void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function dev(projectDir = process.cwd(), options?: DevOptions): Promise<number> {
|
|
63
|
+
const reporter = createReporter(options?.json === true);
|
|
37
64
|
const work = join(projectDir, ".mirin");
|
|
38
65
|
mkdirSync(work, { recursive: true });
|
|
39
66
|
// `bun build --compile` drops temp *.bun-build files in the cwd; clear any left
|
|
@@ -51,25 +78,40 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
51
78
|
// bundling; the file is read from the project at window-create time.
|
|
52
79
|
const linuxIconPng = IS_LINUX && iconSrc ? resolveLinuxIconPng(iconSrc) : undefined;
|
|
53
80
|
|
|
81
|
+
// --- open the dev session (agent devtools) ---
|
|
82
|
+
const session = DevSession.create({
|
|
83
|
+
projectDir,
|
|
84
|
+
appName,
|
|
85
|
+
appId: bundleId,
|
|
86
|
+
version: typeof config.version === "string" ? config.version : undefined,
|
|
87
|
+
});
|
|
88
|
+
|
|
54
89
|
// --- native artifacts ---
|
|
55
|
-
const artifacts = await resolveArtifacts({ release: false });
|
|
90
|
+
const artifacts = await resolveArtifacts({ release: false, reporter });
|
|
56
91
|
|
|
57
92
|
// --- compile the Bun host + bundle the Worker ---
|
|
58
|
-
|
|
93
|
+
const compilePhase = session?.phase("compile");
|
|
94
|
+
reporter.info("[mirin dev] compiling host + bundling main process…");
|
|
95
|
+
reporter.event("compile");
|
|
59
96
|
// `bun build --compile` emits an `.exe` on Windows; name it so explicitly.
|
|
60
97
|
const hostExe = join(work, IS_WINDOWS ? "host.exe" : "host");
|
|
61
98
|
const workerJs = join(work, "worker.js");
|
|
62
99
|
const hostTarget =
|
|
63
100
|
process.platform === "win32" && process.arch === "arm64" ? ["--target=bun-windows-x64"] : [];
|
|
64
|
-
await
|
|
65
|
-
|
|
101
|
+
await reporter.build(
|
|
102
|
+
$`bun build --compile ${hostTarget} ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(
|
|
103
|
+
projectDir,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
await reporter.build(
|
|
107
|
+
$`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir),
|
|
66
108
|
);
|
|
67
|
-
|
|
109
|
+
compilePhase?.ok();
|
|
68
110
|
|
|
69
111
|
// Extra assets (dev): compile workers into .mirin/workers and symlink sidecar
|
|
70
112
|
// binaries into .mirin/sidecars (no copy/sign in dev — they run unsigned locally).
|
|
71
113
|
const workersDir = join(work, "workers");
|
|
72
|
-
await compileWorkers(projectDir, config.workers, workersDir, false);
|
|
114
|
+
await compileWorkers(projectDir, config.workers, workersDir, false, reporter);
|
|
73
115
|
const sidecarsDir = join(work, "sidecars");
|
|
74
116
|
mkdirSync(sidecarsDir, { recursive: true });
|
|
75
117
|
for (const sc of normalizeSidecars(projectDir, config.sidecars)) {
|
|
@@ -80,7 +122,9 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
// --- assemble the dev bundle (Windows/Linux app folder, or macOS .app) ---
|
|
83
|
-
|
|
125
|
+
const bundlePhase = session?.phase("bundle");
|
|
126
|
+
reporter.info("[mirin dev] assembling dev bundle…");
|
|
127
|
+
reporter.event("bundle");
|
|
84
128
|
const { app, exe } = IS_WINDOWS
|
|
85
129
|
? await buildWindowsBundle({
|
|
86
130
|
appName,
|
|
@@ -112,6 +156,7 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
112
156
|
icon: iconSrc,
|
|
113
157
|
urlSchemes: config.urlSchemes,
|
|
114
158
|
});
|
|
159
|
+
bundlePhase?.ok();
|
|
115
160
|
|
|
116
161
|
// Linux: install a dev `.desktop` entry so cosmic's dock can resolve the app's
|
|
117
162
|
// icon. cosmic matches a running X11 window's `WM_CLASS` (which the core sets to
|
|
@@ -140,8 +185,12 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
140
185
|
// both sides to 127.0.0.1 avoids the family mismatch (and matches the loopback
|
|
141
186
|
// RPC server). `--host 127.0.0.1` forces Vite's bind.
|
|
142
187
|
const port = await findFreePort(DEV_PORT_BASE);
|
|
188
|
+
const cdpPort = await findFreePort(CDP_PORT_BASE);
|
|
143
189
|
const devUrl = `http://127.0.0.1:${port}`;
|
|
144
|
-
|
|
190
|
+
const vitePhase = session?.phase("vite");
|
|
191
|
+
session?.setDevUrl(devUrl);
|
|
192
|
+
reporter.info(`[mirin dev] starting Vite dev server on ${devUrl}…`);
|
|
193
|
+
reporter.event("vite", { devUrl });
|
|
145
194
|
const vite = Bun.spawn(
|
|
146
195
|
[
|
|
147
196
|
"bunx",
|
|
@@ -156,13 +205,21 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
156
205
|
],
|
|
157
206
|
{
|
|
158
207
|
cwd: projectDir,
|
|
159
|
-
stdio:
|
|
208
|
+
stdio: reporter.childStdio,
|
|
160
209
|
},
|
|
161
210
|
);
|
|
162
|
-
|
|
211
|
+
try {
|
|
212
|
+
await waitForUrl(devUrl, 15_000);
|
|
213
|
+
vitePhase?.ok(devUrl);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
vitePhase?.fail(err instanceof Error ? err.message : String(err));
|
|
216
|
+
vite.kill();
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
163
219
|
|
|
164
220
|
// --- launch the app ---
|
|
165
|
-
|
|
221
|
+
const launchPhase = session?.phase("launch");
|
|
222
|
+
reporter.info(`[mirin dev] launching ${appName}…`);
|
|
166
223
|
const appProc = Bun.spawn([exe], {
|
|
167
224
|
cwd: projectDir,
|
|
168
225
|
env: {
|
|
@@ -183,7 +240,11 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
183
240
|
: {}),
|
|
184
241
|
MIRIN_WORKER: workerJs,
|
|
185
242
|
MIRIN_DEV_URL: devUrl,
|
|
186
|
-
MIRIN_MANIFEST_JSON: JSON.stringify({
|
|
243
|
+
MIRIN_MANIFEST_JSON: JSON.stringify({
|
|
244
|
+
id: bundleId,
|
|
245
|
+
windows: config.windows,
|
|
246
|
+
devtools: config.devtools,
|
|
247
|
+
}),
|
|
187
248
|
// dev: true → enables inspect-element AND gives this run its own `-dev`
|
|
188
249
|
// CEF cache dir, so `mirin dev` can run alongside the installed app.
|
|
189
250
|
// `icon_path` (Linux) → the window's `_NET_WM_ICON` (cosmic dock/taskbar).
|
|
@@ -193,9 +254,33 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
193
254
|
}),
|
|
194
255
|
MIRIN_SIDECAR_DIR: sidecarsDir,
|
|
195
256
|
MIRIN_WORKERS_DIR: workersDir,
|
|
257
|
+
// CEF's DevTools-protocol port (screenshots, snapshots, eval, input).
|
|
258
|
+
// Loopback only, and only ever set for a dev run (docs/agent-devtools.md).
|
|
259
|
+
MIRIN_CDP_PORT: String(cdpPort),
|
|
260
|
+
...(session?.env() ?? {}),
|
|
196
261
|
},
|
|
197
|
-
stdio:
|
|
262
|
+
stdio: reporter.childStdio,
|
|
198
263
|
});
|
|
264
|
+
session?.setPid(appProc.pid);
|
|
265
|
+
launchPhase?.ok();
|
|
266
|
+
reporter.event("launch", {
|
|
267
|
+
pid: appProc.pid,
|
|
268
|
+
devUrl,
|
|
269
|
+
cdpPort,
|
|
270
|
+
session: session?.paths.dir ?? null,
|
|
271
|
+
});
|
|
272
|
+
if (session) {
|
|
273
|
+
reporter.info(`[mirin dev] dev session: ${session.paths.dir}`);
|
|
274
|
+
// The app publishes its inspector endpoint once the Worker binds it; report it
|
|
275
|
+
// when it lands, without holding up the dev loop if it never does.
|
|
276
|
+
void session.waitForInspector().then((endpoint) => {
|
|
277
|
+
if (endpoint === undefined) return;
|
|
278
|
+
reporter.info(
|
|
279
|
+
`[mirin dev] inspector: http://127.0.0.1:${endpoint.port} — token in inspector.json`,
|
|
280
|
+
);
|
|
281
|
+
reporter.event("inspector", { url: `http://127.0.0.1:${endpoint.port}` });
|
|
282
|
+
});
|
|
283
|
+
}
|
|
199
284
|
|
|
200
285
|
const cleanup = () => {
|
|
201
286
|
vite.kill();
|
|
@@ -204,8 +289,26 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
204
289
|
process.on("SIGINT", cleanup);
|
|
205
290
|
process.on("SIGTERM", cleanup);
|
|
206
291
|
|
|
292
|
+
if (options?.onLaunched !== undefined) {
|
|
293
|
+
try {
|
|
294
|
+
await options.onLaunched({
|
|
295
|
+
...(session !== undefined ? { session } : {}),
|
|
296
|
+
devUrl,
|
|
297
|
+
cdpPort,
|
|
298
|
+
pid: appProc.pid,
|
|
299
|
+
stop: cleanup,
|
|
300
|
+
});
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error(`[mirin] launch hook failed: ${err instanceof Error ? err.message : err}`);
|
|
303
|
+
} finally {
|
|
304
|
+
// A hooked run is a one-shot: stop the app whether the hook succeeded or not.
|
|
305
|
+
cleanup();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
207
309
|
const code = await appProc.exited;
|
|
208
310
|
vite.kill();
|
|
311
|
+
session?.finish(code, appProc.signalCode ?? undefined);
|
|
209
312
|
return code;
|
|
210
313
|
}
|
|
211
314
|
|