@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 +21 -0
- package/README.md +46 -0
- package/bin/vitest-agent.js +39 -0
- package/commands/agent.js +159 -0
- package/commands/db.js +123 -0
- package/commands/doctor.js +111 -0
- package/commands/record.js +204 -0
- package/commands/triage.js +40 -0
- package/commands/wrapup.js +48 -0
- package/index.d.ts +169 -0
- package/index.js +18 -0
- package/layers/CliLive.js +30 -0
- package/layers/SidecarLive.js +34 -0
- package/lib/format-db-query.js +26 -0
- package/lib/format-doctor.js +14 -0
- package/lib/internal-end-agent.js +27 -0
- package/lib/internal-register-agent.js +76 -0
- package/lib/record-session.js +33 -0
- package/lib/record-tdd-artifact.js +40 -0
- package/lib/record-turn.js +44 -0
- package/lib/record-workspace-changes.js +64 -0
- package/lib/resolve-session-for-recording.js +71 -0
- package/lib/sidecar-paths.js +101 -0
- package/package.json +54 -0
- package/tsdoc-metadata.json +11 -0
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
|
+
[](https://www.npmjs.com/package/@vitest-agent/cli)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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 };
|