@mirinjs/cli 0.1.0-alpha.17 → 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 +7 -1
- package/src/check.ts +271 -0
- package/src/dev.ts +120 -13
- 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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output shaping for CLI commands. Invariant: in `--json` mode nothing but JSON
|
|
3
|
+
* reaches stdout, process-wide — `childStdio` and `build()` route child output away.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { $ } from "bun";
|
|
7
|
+
|
|
8
|
+
export type ChildStdio = ["ignore", "inherit" | 2, "inherit"];
|
|
9
|
+
|
|
10
|
+
export interface Reporter {
|
|
11
|
+
readonly json: boolean;
|
|
12
|
+
/** Progress for a person. Suppressed in JSON mode. */
|
|
13
|
+
info(message: string): void;
|
|
14
|
+
/** Progress for a tool. Suppressed in human mode. */
|
|
15
|
+
event(phase: string, data?: Record<string, unknown>): void;
|
|
16
|
+
/** The final outcome. JSON mode prints `payload`; human mode calls `render`. */
|
|
17
|
+
finish(payload: Record<string, unknown>, render: () => void): void;
|
|
18
|
+
/** `stdio` for a long-lived child (Vite, the app): in JSON mode its stdout is
|
|
19
|
+
* pointed at stderr, off the stream a caller parses. */
|
|
20
|
+
readonly childStdio: ChildStdio;
|
|
21
|
+
/** Await a build subprocess under the same rule — `bun build` writes its
|
|
22
|
+
* bundle summary to stdout. */
|
|
23
|
+
build(shell: $.ShellPromise): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createReporter(json: boolean): Reporter {
|
|
27
|
+
return {
|
|
28
|
+
json,
|
|
29
|
+
childStdio: ["ignore", json ? 2 : "inherit", "inherit"],
|
|
30
|
+
info(message) {
|
|
31
|
+
if (!json) console.log(message);
|
|
32
|
+
},
|
|
33
|
+
event(phase, data) {
|
|
34
|
+
if (json) console.log(JSON.stringify({ ts: Date.now(), phase, ...data }));
|
|
35
|
+
},
|
|
36
|
+
async build(shell) {
|
|
37
|
+
// Stream live: a cold `cargo build` takes minutes, and buffering looks like a hang.
|
|
38
|
+
if (!json) {
|
|
39
|
+
await shell;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Replay on stderr rather than dropping, or a failed build reports only its exit status.
|
|
43
|
+
try {
|
|
44
|
+
const output = await shell.quiet();
|
|
45
|
+
writeToStderr(output.stdout, output.stderr);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
writeToStderr(...capturedStreams(err));
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
finish(payload, render) {
|
|
52
|
+
if (json) console.log(JSON.stringify({ ts: Date.now(), phase: "result", ...payload }));
|
|
53
|
+
else render();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A `ShellError` carries the output the failed command produced; a plain Error does not. */
|
|
59
|
+
function capturedStreams(value: unknown): unknown[] {
|
|
60
|
+
if (typeof value !== "object" || value === null) return [];
|
|
61
|
+
const shellError = value as Record<string, unknown>;
|
|
62
|
+
return [shellError.stdout, shellError.stderr];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeToStderr(...chunks: unknown[]): void {
|
|
66
|
+
for (const chunk of chunks) {
|
|
67
|
+
if (chunk instanceof Uint8Array && chunk.byteLength > 0) process.stderr.write(chunk);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and running a `mirin check` scenario — an ordinary module that
|
|
3
|
+
* default-exports an async function (docs/agent-devtools.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8
|
+
import type { CheckScenario } from "mirinjs/check";
|
|
9
|
+
import { createDriver, type ScenarioOutcome } from "./driver.ts";
|
|
10
|
+
import type { InspectorClient } from "./inspector.ts";
|
|
11
|
+
|
|
12
|
+
function message(err: unknown): string {
|
|
13
|
+
return err instanceof Error ? err.message : String(err);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Resolve and import a scenario file. Throws with an actionable message. */
|
|
17
|
+
export async function loadScenario(projectDir: string, file: string): Promise<CheckScenario> {
|
|
18
|
+
const path = isAbsolute(file) ? file : resolve(projectDir, file);
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
throw new Error(`scenario file not found: ${path}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let loaded: unknown;
|
|
24
|
+
try {
|
|
25
|
+
loaded = (await import(path)) as unknown;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new Error(`scenario ${file} failed to load: ${message(err)}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const scenario = (loaded as { default?: unknown }).default;
|
|
31
|
+
if (typeof scenario !== "function") {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`scenario ${file} must default-export a function — see \`defineCheck\` from "mirinjs/check"`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return scenario as CheckScenario;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run `scenario` against the app. Never throws: a failure is a result, so the
|
|
40
|
+
* caller still captures artifacts and writes a report. */
|
|
41
|
+
export async function runScenario(
|
|
42
|
+
client: InspectorClient,
|
|
43
|
+
scenario: CheckScenario,
|
|
44
|
+
onStep?: (name: string) => void,
|
|
45
|
+
): Promise<ScenarioOutcome> {
|
|
46
|
+
const { driver, outcome, finishStep } = createDriver(client, onStep);
|
|
47
|
+
try {
|
|
48
|
+
await scenario(driver);
|
|
49
|
+
finishStep(true);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
finishStep(false);
|
|
52
|
+
outcome.failure = {
|
|
53
|
+
step: outcome.steps.at(-1)?.name ?? "scenario",
|
|
54
|
+
message: message(err),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return outcome;
|
|
58
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-session bookkeeping (docs/agent-devtools.md). The CLI owns `session.json`
|
|
3
|
+
* and `exit.json`; the app's Worker owns `inspector.json` and `events.jsonl` —
|
|
4
|
+
* one writer per file, so nothing needs locking.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createSessionDir,
|
|
9
|
+
DEV_SESSION_ENV,
|
|
10
|
+
type InspectorEndpoint,
|
|
11
|
+
newSessionId,
|
|
12
|
+
readEventsFile,
|
|
13
|
+
readInspectorEndpoint,
|
|
14
|
+
type SessionInfo,
|
|
15
|
+
type SessionPaths,
|
|
16
|
+
type SessionPhase,
|
|
17
|
+
writeCurrentSession,
|
|
18
|
+
writeSessionExit,
|
|
19
|
+
writeSessionInfo,
|
|
20
|
+
} from "mirinjs/devtools/session";
|
|
21
|
+
|
|
22
|
+
/** Events kept in `exit.json`, so the post-mortem is self-contained. */
|
|
23
|
+
const EXIT_TAIL = 50;
|
|
24
|
+
|
|
25
|
+
/** A phase in progress; call exactly one terminal method. */
|
|
26
|
+
export interface PhaseHandle {
|
|
27
|
+
ok(detail?: string): void;
|
|
28
|
+
fail(detail?: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DevSessionInit {
|
|
32
|
+
projectDir: string;
|
|
33
|
+
appName: string;
|
|
34
|
+
appId: string;
|
|
35
|
+
version?: string;
|
|
36
|
+
devUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class DevSession {
|
|
40
|
+
readonly paths: SessionPaths;
|
|
41
|
+
#info: SessionInfo;
|
|
42
|
+
|
|
43
|
+
private constructor(paths: SessionPaths, info: SessionInfo) {
|
|
44
|
+
this.paths = paths;
|
|
45
|
+
this.#info = info;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create `.mirin/dev/<id>/` and point `current.json` at it. Failure is not
|
|
49
|
+
* fatal: a read-only project still runs `mirin dev`, just without artifacts. */
|
|
50
|
+
static create(init: DevSessionInit): DevSession | undefined {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const id = newSessionId(now, process.pid);
|
|
53
|
+
try {
|
|
54
|
+
const paths = createSessionDir(init.projectDir, id);
|
|
55
|
+
const info: SessionInfo = {
|
|
56
|
+
version: 1,
|
|
57
|
+
id,
|
|
58
|
+
app: {
|
|
59
|
+
name: init.appName,
|
|
60
|
+
id: init.appId,
|
|
61
|
+
...(init.version !== undefined ? { version: init.version } : {}),
|
|
62
|
+
},
|
|
63
|
+
projectDir: init.projectDir,
|
|
64
|
+
startedAt: now,
|
|
65
|
+
dev: true,
|
|
66
|
+
...(init.devUrl !== undefined ? { devUrl: init.devUrl } : {}),
|
|
67
|
+
phases: [],
|
|
68
|
+
};
|
|
69
|
+
const session = new DevSession(paths, info);
|
|
70
|
+
session.#save();
|
|
71
|
+
writeCurrentSession(init.projectDir, paths.dir);
|
|
72
|
+
return session;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Env additions that point the app process at this session. */
|
|
79
|
+
env(): Record<string, string> {
|
|
80
|
+
return { [DEV_SESSION_ENV]: this.paths.dir };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Wait for the app's Worker to publish `inspector.json`; gives up quietly
|
|
84
|
+
* rather than delay the dev loop. */
|
|
85
|
+
async waitForInspector(timeoutMs = 10_000): Promise<InspectorEndpoint | undefined> {
|
|
86
|
+
const deadline = Date.now() + timeoutMs;
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
const endpoint = readInspectorEndpoint(this.paths);
|
|
89
|
+
if (endpoint !== undefined) return endpoint;
|
|
90
|
+
await Bun.sleep(100);
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Record the start of a phase and return its terminal handles. */
|
|
96
|
+
phase(name: string): PhaseHandle {
|
|
97
|
+
const startedAt = Date.now();
|
|
98
|
+
this.#push({ name, status: "start", ts: startedAt });
|
|
99
|
+
const settle = (status: "ok" | "fail", detail?: string): void => {
|
|
100
|
+
this.#push({
|
|
101
|
+
name,
|
|
102
|
+
status,
|
|
103
|
+
ts: Date.now(),
|
|
104
|
+
ms: Date.now() - startedAt,
|
|
105
|
+
...(detail !== undefined ? { detail } : {}),
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
ok: (detail) => settle("ok", detail),
|
|
110
|
+
fail: (detail) => settle("fail", detail),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setDevUrl(devUrl: string): void {
|
|
115
|
+
this.#info = { ...this.#info, devUrl };
|
|
116
|
+
this.#save();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setPid(pid: number): void {
|
|
120
|
+
this.#info = { ...this.#info, pid };
|
|
121
|
+
this.#save();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Write the post-mortem. The event tail is included so the file stands alone. */
|
|
125
|
+
finish(code: number | null, signal?: string): void {
|
|
126
|
+
const events = readEventsFile(this.paths.events);
|
|
127
|
+
try {
|
|
128
|
+
writeSessionExit(this.paths, {
|
|
129
|
+
version: 1,
|
|
130
|
+
exitedAt: Date.now(),
|
|
131
|
+
code,
|
|
132
|
+
...(signal !== undefined ? { signal } : {}),
|
|
133
|
+
errorCount: events.filter((event) => event.level === "error").length,
|
|
134
|
+
tail: events.slice(-EXIT_TAIL),
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
// Best effort: the exit code still reaches the caller through the return path.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#push(phase: SessionPhase): void {
|
|
142
|
+
this.#info = { ...this.#info, phases: [...this.#info.phases, phase] };
|
|
143
|
+
this.#save();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#save(): void {
|
|
147
|
+
try {
|
|
148
|
+
writeSessionInfo(this.paths, this.#info);
|
|
149
|
+
} catch {
|
|
150
|
+
// Diagnostics only — never interrupt the dev loop for them.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mirin skill` — install or refresh the agent skill in an existing project.
|
|
3
|
+
* New apps get it from `mirin init`; a stale copy describes a surface that moved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { installSkill } from "create-mirinjs/skill";
|
|
9
|
+
import { createReporter } from "./shared/report.ts";
|
|
10
|
+
|
|
11
|
+
export interface SkillOptions {
|
|
12
|
+
json?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function skill(
|
|
16
|
+
projectDir = process.cwd(),
|
|
17
|
+
options: SkillOptions = {},
|
|
18
|
+
): Promise<number> {
|
|
19
|
+
const reporter = createReporter(options.json === true);
|
|
20
|
+
|
|
21
|
+
// Not fatal: a monorepo may install the skill at its root, away from any manifest.
|
|
22
|
+
const hasManifest = existsSync(join(projectDir, "mirin.config.ts"));
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = installSkill(projectDir);
|
|
26
|
+
reporter.finish(
|
|
27
|
+
{
|
|
28
|
+
ok: true,
|
|
29
|
+
path: result.path,
|
|
30
|
+
replaced: result.replaced,
|
|
31
|
+
mirinProject: hasManifest,
|
|
32
|
+
},
|
|
33
|
+
() => {
|
|
34
|
+
console.log(
|
|
35
|
+
`\n✓ ${result.replaced ? "updated" : "installed"} the mirin agent skill at ${result.relativePath}`,
|
|
36
|
+
);
|
|
37
|
+
if (!hasManifest) {
|
|
38
|
+
console.log(" ! no mirin.config.ts here — is this the project root?");
|
|
39
|
+
}
|
|
40
|
+
console.log(" it teaches agents to verify changes with `mirin check`.\n");
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
return 0;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
46
|
+
reporter.finish({ ok: false, reason: message }, () => {
|
|
47
|
+
console.error(`\n✗ could not install the mirin agent skill: ${message}\n`);
|
|
48
|
+
});
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|