@vitest-agent/cli 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @vitest-agent/cli
2
+
3
+ [![npm](https://img.shields.io/npm/v/@vitest-agent/cli?label=npm&color=cb3837)](https://www.npmjs.com/package/@vitest-agent/cli)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
6
+
7
+ > **Part of the [vitest-agent](https://vitest-agent.dev) ecosystem.** Most users want **[@vitest-agent/plugin](https://www.npmjs.com/package/@vitest-agent/plugin)**, which pulls this package in automatically. Install `@vitest-agent/cli` directly only if you want the `vitest-agent` CLI on its own.
8
+
9
+ The `vitest-agent` CLI bin. Manages the local SQLite database, runs health diagnostics, and provides the hook-plumbing subcommands used by the Claude Code plugin's session and Bash hooks. Reads cached test data from SQLite — never runs tests or calls AI providers.
10
+
11
+ ## Features
12
+
13
+ - **`doctor`** — five-point health diagnostic covering manifest assembly, latest-run integrity and staleness
14
+ - **`db`** — `path`, `prune`, `reset` and `query` subcommands for database lifecycle management
15
+ - **`agent`** namespace — `triage`, `wrapup`, `record`, `register-agent`, `end-agent`, `inject-env` and `sidecar-path` for hook-driven plumbing
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install --save-dev @vitest-agent/cli
21
+ # or
22
+ pnpm add -D @vitest-agent/cli
23
+ ```
24
+
25
+ `@vitest-agent/cli` is a required peer of `@vitest-agent/plugin` and arrives automatically with modern pnpm and npm.
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ npx vitest-agent doctor
31
+ # example output (varies by environment)
32
+
33
+ npx vitest-agent db path
34
+ # prints the XDG-derived path to data.db
35
+
36
+ npx vitest-agent db query "SELECT count(*) FROM test_cases"
37
+ # example output (varies by environment)
38
+ ```
39
+
40
+ ## Documentation
41
+
42
+ CLI reference at [vitest-agent.dev/cli](https://vitest-agent.dev/cli).
43
+
44
+ ## License
45
+
46
+ [MIT](LICENSE)
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { CliLive } from "../layers/CliLive.js";
3
+ import { CURRENT_CLI_VERSION } from "../index.js";
4
+ import { agentCommand } from "../commands/agent.js";
5
+ import { dbCommand } from "../commands/db.js";
6
+ import { doctorCommand } from "../commands/doctor.js";
7
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
8
+ import { CURRENT_SDK_VERSION, PathResolutionLive, formatFatalError, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
9
+ import { Cause, Console, Effect } from "effect";
10
+ import { Command } from "@effect/cli";
11
+
12
+ //#region src/bin.ts
13
+ /**
14
+ * CLI entry point for vitest-agent.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ const rootCommand = Command.make("vitest-agent").pipe(Command.withSubcommands([
19
+ dbCommand,
20
+ doctorCommand,
21
+ agentCommand
22
+ ]));
23
+ const cli = Command.run(rootCommand, {
24
+ name: "vitest-agent",
25
+ version: "0.0.0"
26
+ });
27
+ const logLevel = resolveLogLevel();
28
+ const logFile = resolveLogFile();
29
+ // @vitest-agent/sdk and writes a single stderr line on mismatch.
30
+ if ("1.0.0" !== "0.0.0" && CURRENT_SDK_VERSION !== "1.0.0") process.stderr.write(`[@vitest-agent/cli] version drift: @vitest-agent/cli@${CURRENT_CLI_VERSION} with @vitest-agent/sdk@${CURRENT_SDK_VERSION}. Reinstall @vitest-agent/* packages so versions match.\n`);
31
+ const projectDir = process.env.VITEST_AGENT_PROJECT_DIR ?? process.cwd();
32
+ const main = resolveDataPath(projectDir).pipe(Effect.flatMap((dbPath) => Effect.suspend(() => cli(process.argv)).pipe(Effect.provide(CliLive(dbPath, logLevel, logFile)))), Effect.provide(PathResolutionLive(projectDir)), Effect.provide(NodeContext.layer), Effect.catchAllCause((cause) => {
33
+ if (Cause.defects(cause).length > 0) return Console.error(`vitest-agent: ${formatFatalError(cause)}`).pipe(Effect.andThen(Effect.failCause(cause)));
34
+ return Effect.failCause(cause);
35
+ }));
36
+ NodeRuntime.runMain(main);
37
+
38
+ //#endregion
39
+ export { };
@@ -0,0 +1,159 @@
1
+ import { SidecarLive } from "../layers/SidecarLive.js";
2
+ import { registerAgentEffect } from "../lib/internal-register-agent.js";
3
+ import { DATA_DB_FILENAME, REGISTRY_DB_FILENAME, resolveProjectDataDir, resolveRegistryDir, resolveSessionMapPath } from "../lib/sidecar-paths.js";
4
+ import { endAgentEffect } from "../lib/internal-end-agent.js";
5
+ import { recordCommand } from "./record.js";
6
+ import { triageCommand } from "./triage.js";
7
+ import { wrapupCommand } from "./wrapup.js";
8
+ import { NodeContext } from "@effect/platform-node";
9
+ import { resolveProjectKeyFromCwd } from "@vitest-agent/sdk";
10
+ import { Cause, Chunk, Effect, Option } from "effect";
11
+ import { join } from "node:path";
12
+ import { Command, Options } from "@effect/cli";
13
+ import { exitCodeForTag, injectEnv } from "@vitest-agent/sdk/dispatch";
14
+ import { resolveSidecarBinaryPath } from "@vitest-agent/sidecar";
15
+
16
+ //#region src/commands/agent.ts
17
+ /**
18
+ * `agent` subcommand namespace.
19
+ *
20
+ * Commands intended for agents and hook scripts — humans typically do
21
+ * not invoke these directly. The group composes the hook-driven
22
+ * utilities (triage, wrapup, record) with the sidecar invocations
23
+ * called by plugin/hooks/*.sh scripts (register-agent, end-agent,
24
+ * inject-env).
25
+ *
26
+ * The sidecar subcommands return plain text on stdout that the bash
27
+ * hooks parse, and structured error info on stderr in the shape
28
+ * `<exit_code> <error_tag>: <message>`.
29
+ *
30
+ * Exit codes follow the contract documented in the agent-agnostic
31
+ * taxonomy plan:
32
+ * 0 = success
33
+ * 1 = registration conflict
34
+ * 2 = sidecar timeout
35
+ * 3 = database error
36
+ * 4 = project identity not resolvable
37
+ * 5 = other unexpected defect
38
+ *
39
+ * @packageDocumentation
40
+ */
41
+ const writeStdout = (line) => Effect.sync(() => {
42
+ process.stdout.write(`${line}\n`);
43
+ });
44
+ const writeStderrAndExit = (exitCode, tag, message) => Effect.sync(() => {
45
+ process.stderr.write(`${exitCode} ${tag}: ${message}\n`);
46
+ process.exit(exitCode);
47
+ });
48
+ const mapDefectToExit = (cause) => {
49
+ const failures = Chunk.toReadonlyArray(Cause.failures(cause));
50
+ if (failures.length > 0) {
51
+ const tagged = failures[0];
52
+ const tag = tagged._tag ?? "UnknownError";
53
+ const message = tagged.reason ?? tagged.message ?? String(failures[0]);
54
+ return writeStderrAndExit(exitCodeForTag(tag), tag, message);
55
+ }
56
+ const defect = Chunk.toReadonlyArray(Cause.defects(cause))[0];
57
+ return writeStderrAndExit(5, "Defect", defect instanceof Error ? defect.message : String(defect ?? "unknown defect"));
58
+ };
59
+ const hostKindOpt = Options.text("host-kind").pipe(Options.withDescription("Host vendor identifier; e.g. 'claude-code', 'cursor', 'goose'"));
60
+ const agentTypeOpt = Options.text("agent-type").pipe(Options.withDescription("Agent type, must begin with the host-kind prefix"));
61
+ const hostSessionIdOpt = Options.text("host-session-id").pipe(Options.withDescription("Host's native session id (host chat UUID; `session_id` in the CC hook payload)"));
62
+ const transcriptPathOpt = Options.text("transcript-path").pipe(Options.withDescription("Path to the host's transcript file (basename UUID is the conversation key)"));
63
+ const cwdOpt = Options.text("cwd").pipe(Options.withDescription("Workspace root directory the agent is running in"));
64
+ const parentAgentIdOpt = Options.optional(Options.text("parent-agent-id"));
65
+ const clientNonceOpt = Options.optional(Options.text("client-nonce"));
66
+ const projectKeyOverrideOpt = Options.optional(Options.text("project-key"));
67
+ const registerAgentSubcommand = Command.make("register-agent", {
68
+ hostKind: hostKindOpt,
69
+ agentType: agentTypeOpt,
70
+ hostSessionId: hostSessionIdOpt,
71
+ transcriptPath: transcriptPathOpt,
72
+ cwd: cwdOpt,
73
+ parentAgentId: parentAgentIdOpt,
74
+ clientNonce: clientNonceOpt,
75
+ projectKeyOverride: projectKeyOverrideOpt
76
+ }, (opts) => Effect.gen(function* () {
77
+ const projectKey = Option.isSome(opts.projectKeyOverride) ? opts.projectKeyOverride.value : resolveProjectKeyFromCwd(opts.cwd);
78
+ const perProjectDbPath = join(resolveProjectDataDir(projectKey), DATA_DB_FILENAME);
79
+ const registryDbPath = join(resolveRegistryDir(), REGISTRY_DB_FILENAME);
80
+ const sidecar = SidecarLive({
81
+ perProjectDbPath,
82
+ sessionMapDbPath: yield* resolveSessionMapPath().pipe(Effect.catchAllCause(mapDefectToExit)),
83
+ registryDbPath
84
+ });
85
+ const result = yield* registerAgentEffect({
86
+ hostSessionId: opts.hostSessionId,
87
+ transcriptPath: opts.transcriptPath,
88
+ cwd: opts.cwd,
89
+ hostKind: opts.hostKind,
90
+ agentType: opts.agentType,
91
+ projectKey,
92
+ ...Option.isSome(opts.parentAgentId) && { parentAgentId: opts.parentAgentId.value },
93
+ ...Option.isSome(opts.clientNonce) && { clientNonce: opts.clientNonce.value }
94
+ }).pipe(Effect.provide(sidecar), Effect.catchAllCause(mapDefectToExit));
95
+ yield* writeStdout(JSON.stringify({
96
+ agentId: result.agentId,
97
+ conversationId: result.conversationId,
98
+ mainAgentId: result.mainAgentId,
99
+ idempotencyKey: result.idempotencyKey,
100
+ idempotencyHit: result.idempotencyHit
101
+ }));
102
+ }).pipe(Effect.provide(NodeContext.layer))).pipe(Command.withDescription("Register an agent invocation in the per-project store and the per-client session map"));
103
+ const agentIdOpt = Options.text("agent-id").pipe(Options.withDescription("The agent_id (UUID) returned by an earlier register-agent call"));
104
+ const endedAtOpt = Options.optional(Options.integer("ended-at"));
105
+ const endHostSessionIdOpt = Options.optional(Options.text("host-session-id"));
106
+ const endCwdOpt = Options.text("cwd").pipe(Options.withDefault(process.cwd()), Options.withDescription("Workspace root, used to locate the per-project data.db"));
107
+ const endProjectKeyOverrideOpt = Options.optional(Options.text("project-key"));
108
+ const endAgentSubcommand = Command.make("end-agent", {
109
+ agentId: agentIdOpt,
110
+ endedAt: endedAtOpt,
111
+ hostSessionId: endHostSessionIdOpt,
112
+ cwd: endCwdOpt,
113
+ projectKeyOverride: endProjectKeyOverrideOpt
114
+ }, (opts) => Effect.gen(function* () {
115
+ const perProjectDbPath = join(resolveProjectDataDir(Option.isSome(opts.projectKeyOverride) ? opts.projectKeyOverride.value : resolveProjectKeyFromCwd(opts.cwd)), DATA_DB_FILENAME);
116
+ const registryDbPath = join(resolveRegistryDir(), REGISTRY_DB_FILENAME);
117
+ const sidecar = SidecarLive({
118
+ perProjectDbPath,
119
+ sessionMapDbPath: yield* resolveSessionMapPath().pipe(Effect.catchAllCause(mapDefectToExit)),
120
+ registryDbPath
121
+ });
122
+ const endedAt = Option.isSome(opts.endedAt) ? opts.endedAt.value : Math.floor(Date.now() / 1e3);
123
+ yield* endAgentEffect({
124
+ agentId: opts.agentId,
125
+ endedAt,
126
+ ...Option.isSome(opts.hostSessionId) && { hostSessionId: opts.hostSessionId.value }
127
+ }).pipe(Effect.provide(sidecar), Effect.catchAllCause(mapDefectToExit));
128
+ }).pipe(Effect.provide(NodeContext.layer))).pipe(Command.withDescription("Mark an agent (and optionally its session) as ended"));
129
+ const commandOpt = Options.text("command").pipe(Options.withDescription("The Bash command to (possibly) rewrite with VITEST_AGENT_* env-prefix"));
130
+ const cwdInjectOpt = Options.text("cwd").pipe(Options.withDefault(process.cwd()), Options.withDescription("Working directory; used to find package.json scripts"));
131
+ const injectEnvSubcommand = Command.make("inject-env", {
132
+ command: commandOpt,
133
+ cwd: cwdInjectOpt
134
+ }, (opts) => Effect.sync(() => {
135
+ const out = injectEnv({
136
+ command: opts.command,
137
+ cwd: opts.cwd,
138
+ env: process.env
139
+ });
140
+ process.stdout.write(`${out}\n`);
141
+ })).pipe(Command.withDescription("Rewrite a Bash command to prepend VITEST_AGENT_* env vars when it invokes Vitest"));
142
+ const sidecarPathSubcommand = Command.make("sidecar-path", {}, () => Effect.sync(() => {
143
+ const path = resolveSidecarBinaryPath();
144
+ if (path === null) process.exit(1);
145
+ process.stdout.write(`${path}\n`);
146
+ })).pipe(Command.withDescription("Print the absolute path of the platform sidecar binary resolved via require.resolve"));
147
+ const agentParent = Command.make("agent").pipe(Command.withDescription("Commands intended for agents and hook scripts — humans typically don't invoke these directly."));
148
+ const agentCommand = agentParent.pipe(Command.withSubcommands([
149
+ triageCommand,
150
+ wrapupCommand,
151
+ recordCommand,
152
+ registerAgentSubcommand,
153
+ endAgentSubcommand,
154
+ injectEnvSubcommand,
155
+ sidecarPathSubcommand
156
+ ]));
157
+
158
+ //#endregion
159
+ export { agentCommand };
package/commands/db.js ADDED
@@ -0,0 +1,123 @@
1
+ import { formatDbQuery } from "../lib/format-db-query.js";
2
+ import { NodeContext } from "@effect/platform-node";
3
+ import { layer } from "@effect/sql-sqlite-node/SqliteClient";
4
+ import { DataStore, resolveDataPath } from "@vitest-agent/sdk";
5
+ import { Effect } from "effect";
6
+ import { Args, Command, Options } from "@effect/cli";
7
+ import { SqlClient } from "@effect/sql/SqlClient";
8
+ import * as readline from "node:readline";
9
+ import { FileSystem } from "@effect/platform";
10
+
11
+ //#region src/commands/db.ts
12
+ /**
13
+ * CLI db command -- manage the vitest-agent database.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ const pathCommand = Command.make("path", {}, () => Effect.gen(function* () {
18
+ const dbPath = yield* resolveDataPath(process.env.VITEST_AGENT_PROJECT_DIR ?? process.cwd());
19
+ yield* Effect.sync(() => process.stdout.write(`${dbPath}\n`));
20
+ })).pipe(Command.withDescription("Print the resolved database path"));
21
+ const keepRecentOption = Options.withDefault(Options.integer("keep-recent"), 30).pipe(Options.withDescription("Number of most-recent sessions to keep in full"));
22
+ const pruneCommand = Command.make("prune", { keepRecent: keepRecentOption }, ({ keepRecent }) => Effect.gen(function* () {
23
+ const result = yield* (yield* DataStore).pruneSessions(keepRecent);
24
+ yield* Effect.sync(() => process.stdout.write(`Pruned ${result.prunedTurns} turn row(s) across ${result.affectedSessions} session(s); session rows retained.\n`));
25
+ })).pipe(Command.withDescription("Drop old sessions' turn history (W1 retention; keeps the last N in full)"));
26
+ const yesOption = Options.boolean("yes").pipe(Options.withDefault(false), Options.withDescription("Skip the interactive confirmation prompt"));
27
+ const resetCommand = Command.make("reset", { yes: yesOption }, ({ yes }) => Effect.gen(function* () {
28
+ const agentId = process.env.VITEST_AGENT_AGENT_ID;
29
+ if (agentId !== void 0 && agentId.length > 0) {
30
+ yield* Effect.sync(() => {
31
+ process.stderr.write("db reset is human-only; use db prune or run from a human terminal\n");
32
+ process.exit(4);
33
+ });
34
+ return;
35
+ }
36
+ const dbPath = yield* resolveDataPath(process.env.VITEST_AGENT_PROJECT_DIR ?? process.cwd());
37
+ if (!process.stdout.isTTY && !yes) {
38
+ yield* Effect.sync(() => {
39
+ process.stderr.write("db reset requires --yes when stdout is not a TTY\n");
40
+ process.exit(5);
41
+ });
42
+ return;
43
+ }
44
+ if (process.stdout.isTTY && !yes) {
45
+ if (!(yield* Effect.promise(() => {
46
+ return new Promise((resolve) => {
47
+ const rl = readline.createInterface({
48
+ input: process.stdin,
49
+ output: process.stdout
50
+ });
51
+ rl.question(`Wipe ${dbPath}? [y/N]: `, (answer) => {
52
+ rl.close();
53
+ resolve(answer === "y" || answer === "Y");
54
+ });
55
+ });
56
+ }))) {
57
+ yield* Effect.sync(() => {
58
+ process.stdout.write("aborted\n");
59
+ process.exit(0);
60
+ });
61
+ return;
62
+ }
63
+ }
64
+ const fs = yield* FileSystem.FileSystem;
65
+ yield* fs.remove(dbPath).pipe(Effect.catchAll(() => Effect.void));
66
+ yield* fs.remove(`${dbPath}-shm`).pipe(Effect.catchAll(() => Effect.void));
67
+ yield* fs.remove(`${dbPath}-wal`).pipe(Effect.catchAll(() => Effect.void));
68
+ yield* Effect.sync(() => {
69
+ process.stdout.write(`Deleted database at ${dbPath}\n`);
70
+ });
71
+ }).pipe(Effect.provide(NodeContext.layer))).pipe(Command.withDescription("Wipe the database (human-only; blocked in agent contexts)"));
72
+ const queryFormatOption = Options.choice("format", ["table", "json"]).pipe(Options.withDefault("table"), Options.withDescription("Output format for query results"));
73
+ const sqlArg = Args.text({ name: "sql" }).pipe(Args.withDescription("Read-only SQL statement to execute against data.db"));
74
+ /**
75
+ * Flatten an error and its cause chain into a single message so the
76
+ * driver's `attempt to write a readonly database` text surfaces to
77
+ * the user regardless of which layer wrapped it.
78
+ */
79
+ const describeError = (error) => {
80
+ if (!(error instanceof Error)) return String(error);
81
+ const parts = [error.message];
82
+ let cause = error.cause;
83
+ while (cause instanceof Error && !parts.includes(cause.message)) {
84
+ parts.push(cause.message);
85
+ cause = cause.cause;
86
+ }
87
+ return parts.filter((part) => part.length > 0).join(": ");
88
+ };
89
+ const queryCommand = Command.make("query", {
90
+ sql: sqlArg,
91
+ format: queryFormatOption
92
+ }, ({ sql, format }) => Effect.gen(function* () {
93
+ if (sql.trim().length === 0) {
94
+ yield* Effect.sync(() => {
95
+ process.stderr.write("db query: missing sql\n");
96
+ process.exit(2);
97
+ });
98
+ return;
99
+ }
100
+ const dbPath = yield* resolveDataPath(process.env.VITEST_AGENT_PROJECT_DIR ?? process.cwd());
101
+ yield* Effect.gen(function* () {
102
+ const rows = yield* (yield* SqlClient).unsafe(sql);
103
+ yield* Effect.sync(() => {
104
+ process.stdout.write(`${formatDbQuery(rows, format)}\n`);
105
+ });
106
+ }).pipe(Effect.provide(layer({
107
+ filename: dbPath,
108
+ readonly: true
109
+ })), Effect.catchAll((error) => Effect.sync(() => {
110
+ process.stderr.write(`db query: ${describeError(error)}\n`);
111
+ process.exit(3);
112
+ })));
113
+ })).pipe(Command.withDescription("Run a read-only SQL query against the database"));
114
+ const dbParent = Command.make("db").pipe(Command.withDescription("Manage the vitest-agent database"));
115
+ const dbCommand = dbParent.pipe(Command.withSubcommands([
116
+ pathCommand,
117
+ pruneCommand,
118
+ resetCommand,
119
+ queryCommand
120
+ ]));
121
+
122
+ //#endregion
123
+ export { dbCommand };
@@ -0,0 +1,111 @@
1
+ import { formatDoctor } from "../lib/format-doctor.js";
2
+ import { DataReader, resolveDataPath } from "@vitest-agent/sdk";
3
+ import { Effect, Option } from "effect";
4
+ import { Command, Options } from "@effect/cli";
5
+
6
+ //#region src/commands/doctor.ts
7
+ /**
8
+ * CLI doctor command -- diagnose database health.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ const formatOption = Options.withDefault(Options.choice("format", ["markdown", "json"]), "markdown");
13
+ const writeOutput = (results, format) => Effect.sync(() => {
14
+ if (format === "json") process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
15
+ else process.stdout.write(`${formatDoctor(results)}\n`);
16
+ });
17
+ const doctorCommand = Command.make("doctor", { format: formatOption }, ({ format }) => Effect.gen(function* () {
18
+ const reader = yield* DataReader;
19
+ const results = [];
20
+ const dbPath = yield* resolveDataPath(process.cwd());
21
+ results.push({
22
+ name: "Database path",
23
+ passed: true,
24
+ detail: `\`${dbPath}\``
25
+ });
26
+ const manifestOpt = yield* reader.getManifest().pipe(Effect.catchAll(() => Effect.succeed(Option.none())));
27
+ if (Option.isNone(manifestOpt)) {
28
+ results.push({
29
+ name: "Manifest",
30
+ passed: false,
31
+ detail: "no test run data found in database"
32
+ });
33
+ yield* writeOutput(results, format);
34
+ yield* Effect.sync(() => process.exit(1));
35
+ return;
36
+ }
37
+ const manifest = manifestOpt.value;
38
+ results.push({
39
+ name: "Manifest valid",
40
+ passed: true,
41
+ detail: `${manifest.projects.length} project${manifest.projects.length !== 1 ? "s" : ""}`
42
+ });
43
+ let validReports = 0;
44
+ const reportIssues = [];
45
+ for (const entry of manifest.projects) {
46
+ const project = entry.project;
47
+ const reportOpt = yield* reader.getLatestRun(project).pipe(Effect.catchAll(() => Effect.succeed(Option.none())));
48
+ if (Option.isNone(reportOpt)) reportIssues.push(`\`${entry.project}\` no report data`);
49
+ else validReports++;
50
+ }
51
+ const totalReports = manifest.projects.length;
52
+ if (reportIssues.length > 0) results.push({
53
+ name: "Reports",
54
+ passed: false,
55
+ detail: `${validReports}/${totalReports} valid -- ${reportIssues.join(", ")}`
56
+ });
57
+ else results.push({
58
+ name: "Reports",
59
+ passed: true,
60
+ detail: `${validReports}/${totalReports} valid`
61
+ });
62
+ let validHistory = 0;
63
+ let totalHistory = 0;
64
+ const historyIssues = [];
65
+ for (const entry of manifest.projects) {
66
+ totalHistory++;
67
+ const project = entry.project;
68
+ if (!(yield* reader.getHistory(project).pipe(Effect.map((h) => ({
69
+ ok: true,
70
+ record: h
71
+ })), Effect.catchAll(() => Effect.succeed({
72
+ ok: false,
73
+ record: null
74
+ })))).ok) historyIssues.push(`\`${entry.project}\` history read error`);
75
+ else validHistory++;
76
+ }
77
+ if (totalHistory > 0) if (historyIssues.length > 0) results.push({
78
+ name: "History",
79
+ passed: false,
80
+ detail: `${validHistory}/${totalHistory} valid -- ${historyIssues.join(", ")}`
81
+ });
82
+ else results.push({
83
+ name: "History",
84
+ passed: true,
85
+ detail: `${validHistory}/${totalHistory} valid`
86
+ });
87
+ const timestamps = manifest.projects.map((e) => e.lastRun).filter((t) => t !== null);
88
+ if (timestamps.length > 0) {
89
+ const latest = new Date(Math.max(...timestamps.map((t) => new Date(t).getTime())));
90
+ const ageMs = Date.now() - latest.getTime();
91
+ const ageMinutes = Math.floor(ageMs / 6e4);
92
+ const ageHours = Math.floor(ageMs / 36e5);
93
+ const ageDays = Math.floor(ageMs / 864e5);
94
+ let ageStr;
95
+ if (ageMinutes < 1) ageStr = "just now";
96
+ else if (ageMinutes < 60) ageStr = `${ageMinutes} minute${ageMinutes !== 1 ? "s" : ""} ago`;
97
+ else if (ageHours < 24) ageStr = `${ageHours} hour${ageHours !== 1 ? "s" : ""} ago`;
98
+ else ageStr = `${ageDays} day${ageDays !== 1 ? "s" : ""} ago`;
99
+ const isStale = ageHours >= 24;
100
+ results.push({
101
+ name: "Last run",
102
+ passed: !isStale,
103
+ detail: isStale ? `${ageStr} (stale)` : ageStr
104
+ });
105
+ }
106
+ yield* writeOutput(results, format);
107
+ if (results.some((r) => !r.passed)) yield* Effect.sync(() => process.exit(1));
108
+ }));
109
+
110
+ //#endregion
111
+ export { doctorCommand };