@pugi/cli 0.1.0-beta.16 → 0.1.0-beta.18
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/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +96 -27
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/repl/session.js +64 -5
- package/dist/core/repl/slash-commands.js +9 -0
- package/dist/runtime/cli.js +153 -64
- package/dist/runtime/commands/doctor.js +357 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/package.json +2 -2
package/dist/runtime/cli.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { statSync } from 'node:fs';
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
|
|
8
|
-
import { NoopEngineAdapter } from '../core/engine/noop.js';
|
|
9
8
|
import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
|
|
10
|
-
import { decidePermission } from '../core/permission.js';
|
|
11
9
|
import { loadMcpRegistry } from '../core/mcp/registry.js';
|
|
12
10
|
import { loadHookRegistryOrExit } from './load-hooks-or-exit.js';
|
|
13
11
|
import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
|
|
@@ -16,7 +14,6 @@ import { loadSettings } from '../core/settings.js';
|
|
|
16
14
|
import { FileReadCache } from '../core/file-cache.js';
|
|
17
15
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
18
16
|
import { globTool, grepTool, readTool } from '../tools/file-tools.js';
|
|
19
|
-
import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
|
|
20
17
|
import { webFetchTool } from '../tools/web-fetch.js';
|
|
21
18
|
import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
|
|
22
19
|
import { signatureForPlanReview } from '../core/repl/ask.js';
|
|
@@ -29,6 +26,8 @@ import { runDeployCommand } from '../commands/deploy.js';
|
|
|
29
26
|
import { runJobsCommand } from '../commands/jobs.js';
|
|
30
27
|
import { runConfigCommand } from './commands/config.js';
|
|
31
28
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
29
|
+
import { runReport } from './commands/report.js';
|
|
30
|
+
import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
|
|
32
31
|
import { runUndoCommand } from './commands/undo.js';
|
|
33
32
|
import { runBudgetCommand } from './commands/budget.js';
|
|
34
33
|
import { runSkillsCommand } from './commands/skills.js';
|
|
@@ -90,6 +89,10 @@ const handlers = {
|
|
|
90
89
|
plan: runEngineTask('plan'),
|
|
91
90
|
'plan-review': dispatchPlanReview,
|
|
92
91
|
privacy: dispatchPrivacy,
|
|
92
|
+
// PAVF-7 (2026-05-27): `pugi report --from-error` captures the
|
|
93
|
+
// most-recent failed session as a redacted bundle so operators can
|
|
94
|
+
// file clean bug reports without manual log-grepping.
|
|
95
|
+
report: dispatchReport,
|
|
93
96
|
review,
|
|
94
97
|
resume,
|
|
95
98
|
roster: dispatchRoster,
|
|
@@ -271,6 +274,25 @@ async function dispatchPrivacy(args, flags, _session) {
|
|
|
271
274
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
272
275
|
});
|
|
273
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
|
|
279
|
+
* recent failed session into a redacted local report so operators can
|
|
280
|
+
* file clean bug tickets without manual log-grepping. v1 is local-only
|
|
281
|
+
* (no auto-upload — see commands/report.ts header for the rationale).
|
|
282
|
+
*/
|
|
283
|
+
async function dispatchReport(args, flags, _session) {
|
|
284
|
+
const rc = runReport(args, {
|
|
285
|
+
cwd: process.cwd(),
|
|
286
|
+
json: flags.json,
|
|
287
|
+
emit: (line) => {
|
|
288
|
+
if (!flags.json)
|
|
289
|
+
process.stdout.write(line);
|
|
290
|
+
},
|
|
291
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
292
|
+
});
|
|
293
|
+
if (rc !== 0)
|
|
294
|
+
process.exitCode = rc;
|
|
295
|
+
}
|
|
274
296
|
/**
|
|
275
297
|
* `pugi roster` - α7.5 Phase 1.
|
|
276
298
|
*
|
|
@@ -973,6 +995,16 @@ const COMMAND_HELP_BODIES = {
|
|
|
973
995
|
'event log, settings), permission mode, and the capability matrix per',
|
|
974
996
|
'engine adapter. Safe to run anywhere; no network calls.',
|
|
975
997
|
],
|
|
998
|
+
report: [
|
|
999
|
+
'pugi report — capture a bug report from the most-recent session.',
|
|
1000
|
+
'',
|
|
1001
|
+
' --from-error Bundle the most-recent failed session as a',
|
|
1002
|
+
' redacted local report (default + only mode in v1).',
|
|
1003
|
+
'',
|
|
1004
|
+
'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
|
|
1005
|
+
'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
|
|
1006
|
+
'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
|
|
1007
|
+
],
|
|
976
1008
|
ask: [
|
|
977
1009
|
'pugi ask "<question>" — surface a yes/no question modal locally.',
|
|
978
1010
|
'',
|
|
@@ -1077,61 +1109,29 @@ async function help(args, flags, _session) {
|
|
|
1077
1109
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
1078
1110
|
].join('\n'));
|
|
1079
1111
|
}
|
|
1112
|
+
/**
|
|
1113
|
+
* `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
|
|
1114
|
+
* probe runner in `runtime/commands/doctor.ts`. The handler stays
|
|
1115
|
+
* thin so the probe surface stays single-sourced between the CLI
|
|
1116
|
+
* shell command, the `pnpm run doctor --json` package script, and
|
|
1117
|
+
* the in-REPL `/doctor` slash command.
|
|
1118
|
+
*
|
|
1119
|
+
* Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
|
|
1120
|
+
* 2 = at least one error probe). The pre-L17 minimal doctor surface
|
|
1121
|
+
* (adapter capabilities + schema bundle hash) is preserved under
|
|
1122
|
+
* `payload.meta.legacy` so any operator scripts that grep the JSON
|
|
1123
|
+
* keep working through the transition; the field is marked for
|
|
1124
|
+
* removal in a follow-up sprint once the new shape is the
|
|
1125
|
+
* documented contract.
|
|
1126
|
+
*/
|
|
1080
1127
|
async function doctor(_args, flags, _session) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
return {
|
|
1089
|
-
stop: 'error',
|
|
1090
|
-
code: 'failed',
|
|
1091
|
-
message: 'doctor: inert client',
|
|
1092
|
-
};
|
|
1093
|
-
},
|
|
1094
|
-
};
|
|
1095
|
-
const adapters = [
|
|
1096
|
-
new NoopEngineAdapter(),
|
|
1097
|
-
new NativePugiEngineAdapter({ client: inertClient }),
|
|
1098
|
-
];
|
|
1099
|
-
const capabilities = await Promise.all(adapters.map(async (adapter) => ({
|
|
1100
|
-
name: adapter.name,
|
|
1101
|
-
capabilities: await adapter.capabilities(),
|
|
1102
|
-
})));
|
|
1103
|
-
const payload = {
|
|
1104
|
-
cliVersion: PUGI_CLI_VERSION,
|
|
1105
|
-
nodeVersion: process.version,
|
|
1106
|
-
workspaceRoot: cwd,
|
|
1107
|
-
pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
|
|
1108
|
-
pugiDir: existsSync(resolve(cwd, '.pugi')),
|
|
1109
|
-
eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
|
|
1110
|
-
permissionMode: settings.permissions.mode,
|
|
1111
|
-
approvals: settings.workflow.approvals,
|
|
1112
|
-
notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
|
|
1113
|
-
protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
|
|
1114
|
-
protectedFileSafety: 'configured-in-m1',
|
|
1115
|
-
mcpTrust: 'not-configured',
|
|
1116
|
-
releaseGuard: 'scaffolded',
|
|
1117
|
-
tools: toolRegistry,
|
|
1118
|
-
engineAdapters: capabilities,
|
|
1119
|
-
schemaBundleHash: createHash('sha256')
|
|
1120
|
-
.update(toolSchemaBundleHashInput())
|
|
1121
|
-
.digest('hex'),
|
|
1122
|
-
};
|
|
1123
|
-
writeOutput(flags, payload, [
|
|
1124
|
-
'Pugi doctor',
|
|
1125
|
-
`CLI: ${payload.cliVersion}`,
|
|
1126
|
-
`Node: ${payload.nodeVersion}`,
|
|
1127
|
-
`Workspace: ${payload.workspaceRoot}`,
|
|
1128
|
-
`Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
|
|
1129
|
-
`Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
|
|
1130
|
-
`Event log: ${payload.eventLog ? 'present' : 'missing'}`,
|
|
1131
|
-
`Permission mode: ${payload.permissionMode}`,
|
|
1132
|
-
`Approvals: ${payload.approvals}`,
|
|
1133
|
-
`Release guard: ${payload.releaseGuard}`,
|
|
1134
|
-
].join('\n'));
|
|
1128
|
+
await runDoctorCommand({
|
|
1129
|
+
cwd: process.cwd(),
|
|
1130
|
+
home: defaultDoctorHome(),
|
|
1131
|
+
env: process.env,
|
|
1132
|
+
json: flags.json,
|
|
1133
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1134
|
+
});
|
|
1135
1135
|
}
|
|
1136
1136
|
/**
|
|
1137
1137
|
* Programmatic init scaffolder. Idempotent — every helper call is a
|
|
@@ -2140,12 +2140,45 @@ async function performTripleProviderReview(root, session, flags, prompt) {
|
|
|
2140
2140
|
`Refusing to submit an empty diff for review.`);
|
|
2141
2141
|
}
|
|
2142
2142
|
const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
|
|
2143
|
-
|
|
2143
|
+
// merge-base is intentionally a PROBE: an empty result is a valid
|
|
2144
|
+
// signal (orphan branch, shallow clone, moved tag) that the dispatch
|
|
2145
|
+
// path handles by falling back к range-notation. Use the legacy
|
|
2146
|
+
// `safeGit` (probe semantics) explicitly rather than the strict
|
|
2147
|
+
// variant.
|
|
2148
|
+
const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
|
|
2149
|
+
// 2026-05-27 (Claude review followup #489): when merge-base returns empty
|
|
2150
|
+
// (orphan branch, shallow clone, moved tag), we MUST NOT pass the
|
|
2151
|
+
// `<range> <commitRef>` two-arg form to `git diff` — that combo is
|
|
2152
|
+
// invalid syntax, git exits 129, `safeGit` swallows the error, and the
|
|
2153
|
+
// diff payload ships empty. An empty diff is then classified as
|
|
2154
|
+
// `'code'` server-side, dispatched to reviewers who emit a trivial
|
|
2155
|
+
// `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
|
|
2156
|
+
// nobody actually examined. Branch on `mergeBase` так что:
|
|
2157
|
+
// - mergeBase present → `git diff <mergeBase> <commitRef> --`
|
|
2158
|
+
// (both endpoints explicit, only-uncommitted-against-base ignored
|
|
2159
|
+
// because commitRef is a SHA, not HEAD).
|
|
2160
|
+
// - mergeBase empty → `git diff <baseRef>..<commitRef> --`
|
|
2161
|
+
// (range form encodes both endpoints; do NOT append commitRef
|
|
2162
|
+
// again or git rejects the args).
|
|
2144
2163
|
const diffRange = mergeBase || `${baseRef}..${commitRef}`;
|
|
2145
|
-
const diffArgs =
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
const
|
|
2164
|
+
const diffArgs = mergeBase
|
|
2165
|
+
? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
|
|
2166
|
+
: ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
2167
|
+
const diffStatArgs = mergeBase
|
|
2168
|
+
? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
|
|
2169
|
+
: ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
2170
|
+
// Use the strict variant — a non-empty diffPatch is load-bearing for
|
|
2171
|
+
// the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
|
|
2172
|
+
// permission), we'd rather surface a hard error than ship a green
|
|
2173
|
+
// review on nothing. The `--shortstat` companion uses the same
|
|
2174
|
+
// helper so the throw is symmetric.
|
|
2175
|
+
const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
|
|
2176
|
+
const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
|
|
2177
|
+
if (diffPatch.trim() === '') {
|
|
2178
|
+
throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
|
|
2179
|
+
`Refusing to dispatch a review for zero changes — check the refs ` +
|
|
2180
|
+
`or commit your changes before running.`);
|
|
2181
|
+
}
|
|
2149
2182
|
const requestBody = pugiTripleReviewRequestSchema.parse({
|
|
2150
2183
|
schema: 1,
|
|
2151
2184
|
workspace: {
|
|
@@ -5039,7 +5072,31 @@ function fileBytes(path) {
|
|
|
5039
5072
|
return 0;
|
|
5040
5073
|
}
|
|
5041
5074
|
}
|
|
5042
|
-
|
|
5075
|
+
/**
|
|
5076
|
+
* Git invocation helpers — probe vs required semantics.
|
|
5077
|
+
*
|
|
5078
|
+
* 2026-05-27 (Claude review followup #489): the historical `safeGit`
|
|
5079
|
+
* collapsed BOTH "tell me the branch name if you can" probes AND
|
|
5080
|
+
* "give me the diff or fail" hard requirements into a single helper
|
|
5081
|
+
* that swallowed every error as an empty string. That's the correct
|
|
5082
|
+
* shape for the probe case (branch / status / dirty flag — empty
|
|
5083
|
+
* result is a valid signal) but catastrophically wrong for the diff
|
|
5084
|
+
* case (empty result === false PASS on a commit nobody reviewed).
|
|
5085
|
+
*
|
|
5086
|
+
* The split:
|
|
5087
|
+
* - `safeGitProbe` — best-effort. Returns '' on any error. Use for
|
|
5088
|
+
* branch name lookups, status probes, opt-in dirty detection.
|
|
5089
|
+
* - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
|
|
5090
|
+
* Use for diff, merge-base resolution, anything whose empty
|
|
5091
|
+
* output would silently corrupt downstream behaviour.
|
|
5092
|
+
*
|
|
5093
|
+
* Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
|
|
5094
|
+
* so existing call-sites (branch detection, status, etc.) keep their
|
|
5095
|
+
* tolerant semantics until they are individually migrated. Diff /
|
|
5096
|
+
* merge-base / rev-parse-verify call-sites are migrated к
|
|
5097
|
+
* `safeGitRequired` in this same patch.
|
|
5098
|
+
*/
|
|
5099
|
+
export function safeGitProbe(root, args) {
|
|
5043
5100
|
try {
|
|
5044
5101
|
return execFileSync('git', args, {
|
|
5045
5102
|
cwd: root,
|
|
@@ -5057,6 +5114,38 @@ function safeGit(root, args) {
|
|
|
5057
5114
|
return '';
|
|
5058
5115
|
}
|
|
5059
5116
|
}
|
|
5117
|
+
/**
|
|
5118
|
+
* Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
|
|
5119
|
+
* failure. The thrown error carries the operation context so the
|
|
5120
|
+
* caller (triple-review dispatch, etc.) can fail loud rather than
|
|
5121
|
+
* ship an empty diff to a remote reviewer.
|
|
5122
|
+
*/
|
|
5123
|
+
export function safeGitRequired(root, args, context) {
|
|
5124
|
+
try {
|
|
5125
|
+
return execFileSync('git', args, {
|
|
5126
|
+
cwd: root,
|
|
5127
|
+
encoding: 'utf8',
|
|
5128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
5129
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
5130
|
+
});
|
|
5131
|
+
}
|
|
5132
|
+
catch (err) {
|
|
5133
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
5134
|
+
throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
|
|
5135
|
+
`Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
/**
|
|
5139
|
+
* Deprecated alias preserved for diff / status / branch probes that
|
|
5140
|
+
* legitimately want a tolerant empty-string-on-error shape. New call
|
|
5141
|
+
* sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
|
|
5142
|
+
*
|
|
5143
|
+
* @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
|
|
5144
|
+
* `safeGitRequired` (strict, throws).
|
|
5145
|
+
*/
|
|
5146
|
+
function safeGit(root, args) {
|
|
5147
|
+
return safeGitProbe(root, args);
|
|
5148
|
+
}
|
|
5060
5149
|
/**
|
|
5061
5150
|
* Glob patterns excluded from triple-review `diffPatch` before egress.
|
|
5062
5151
|
*
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi doctor` — environment health report (Leak L17, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Parity command with Claude Code's `/doctor` (gap doc:
|
|
5
|
+
* docs/research/2026-05-27-pugi-gap-analysis-3-repos.md §6). Probes
|
|
6
|
+
* auth, API reachability, CLI version, workspace state, disk space,
|
|
7
|
+
* Node version, pnpm, git, MCP servers, config file, and session
|
|
8
|
+
* activity. Emits either a human-readable table OR a structured JSON
|
|
9
|
+
* envelope depending on `--json`.
|
|
10
|
+
*
|
|
11
|
+
* Module contract:
|
|
12
|
+
*
|
|
13
|
+
* - This file owns the WIRING from CLI flags + workspace context to
|
|
14
|
+
* the probe runner. The probes themselves live in
|
|
15
|
+
* `core/diagnostics/probes/*.ts` and have NO module-level coupling
|
|
16
|
+
* to the CLI dispatch surface.
|
|
17
|
+
*
|
|
18
|
+
* - `runDoctorCommand` is the single entry point. Both the top-level
|
|
19
|
+
* `pugi doctor` handler in `runtime/cli.ts` AND the in-REPL
|
|
20
|
+
* `/doctor` slash command call it. The function returns the
|
|
21
|
+
* `DoctorReport` so the REPL can render via the Ink table without
|
|
22
|
+
* re-running the probes.
|
|
23
|
+
*
|
|
24
|
+
* - Exit codes are derived from `exitCodeFor(overall)` in
|
|
25
|
+
* `core/diagnostics/types.ts` and bubble up via `process.exitCode`
|
|
26
|
+
* (matches the convention of every other CLI handler in cli.ts).
|
|
27
|
+
*
|
|
28
|
+
* - The MCP probe is opportunistic: if `core/mcp/registry.js` is
|
|
29
|
+
* unavailable for any reason (e.g. sibling L13 not yet landed,
|
|
30
|
+
* unexpected schema change), the probe degrades to a graceful
|
|
31
|
+
* `skipped` result so the rest of the table still renders.
|
|
32
|
+
*/
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { constants as fsConstants, existsSync, accessSync, readFileSync, statSync } from 'node:fs';
|
|
35
|
+
import { homedir } from 'node:os';
|
|
36
|
+
import { resolveActiveCredential } from '../../core/credentials.js';
|
|
37
|
+
import { PUGI_CLI_VERSION } from '../version.js';
|
|
38
|
+
import { runProbes, } from '../../core/diagnostics/probe-runner.js';
|
|
39
|
+
import { computeOverall, countProbes, exitCodeFor, } from '../../core/diagnostics/types.js';
|
|
40
|
+
import { probeAuth } from '../../core/diagnostics/probes/auth.js';
|
|
41
|
+
import { probeApi } from '../../core/diagnostics/probes/api.js';
|
|
42
|
+
import { probeCliVersion } from '../../core/diagnostics/probes/cli-version.js';
|
|
43
|
+
import { probeWorkspace } from '../../core/diagnostics/probes/workspace.js';
|
|
44
|
+
import { probeDisk } from '../../core/diagnostics/probes/disk.js';
|
|
45
|
+
import { probeNode } from '../../core/diagnostics/probes/node.js';
|
|
46
|
+
import { probePnpm } from '../../core/diagnostics/probes/pnpm.js';
|
|
47
|
+
import { probeGit } from '../../core/diagnostics/probes/git.js';
|
|
48
|
+
import { probeMcp } from '../../core/diagnostics/probes/mcp.js';
|
|
49
|
+
import { probeConfig } from '../../core/diagnostics/probes/config.js';
|
|
50
|
+
import { probeSession } from '../../core/diagnostics/probes/session.js';
|
|
51
|
+
/**
|
|
52
|
+
* Default API URL when no PUGI_API_URL env override is set. Mirrors
|
|
53
|
+
* the constant in `core/credentials.ts` (kept local to avoid an
|
|
54
|
+
* extra named export from that module).
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_API_URL = 'https://api.pugi.io';
|
|
57
|
+
/**
|
|
58
|
+
* Build the standard probe set with production dependencies. Exported
|
|
59
|
+
* for the spec so the test can construct the same suite with stub
|
|
60
|
+
* deps + assert per-probe ordering + fail-isolation in isolation.
|
|
61
|
+
*/
|
|
62
|
+
export function buildDefaultProbes(ctx, options = {}) {
|
|
63
|
+
const fetchImpl = ctx.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
64
|
+
const now = Date.now;
|
|
65
|
+
const probes = [
|
|
66
|
+
{
|
|
67
|
+
name: 'AUTH',
|
|
68
|
+
run: () => probeAuth(ctx, {
|
|
69
|
+
resolveCredential: (env, home) => {
|
|
70
|
+
const credential = resolveActiveCredential(env, home);
|
|
71
|
+
if (!credential)
|
|
72
|
+
return null;
|
|
73
|
+
return { apiUrl: credential.apiUrl, apiKey: credential.apiKey };
|
|
74
|
+
},
|
|
75
|
+
fetchImpl,
|
|
76
|
+
now,
|
|
77
|
+
}),
|
|
78
|
+
timeoutMs: 4_000,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'API',
|
|
82
|
+
run: () => probeApi(ctx, {
|
|
83
|
+
resolveApiUrl: (env) => {
|
|
84
|
+
return env.PUGI_API_URL ?? DEFAULT_API_URL;
|
|
85
|
+
},
|
|
86
|
+
fetchImpl,
|
|
87
|
+
now,
|
|
88
|
+
}),
|
|
89
|
+
timeoutMs: 4_000,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'CLI VERSION',
|
|
93
|
+
run: () => probeCliVersion({
|
|
94
|
+
localVersion: options.localCliVersion ?? PUGI_CLI_VERSION,
|
|
95
|
+
fetchImpl,
|
|
96
|
+
now,
|
|
97
|
+
}),
|
|
98
|
+
timeoutMs: 4_000,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'WORKSPACE',
|
|
102
|
+
run: async () => probeWorkspace(ctx, {
|
|
103
|
+
existsSync,
|
|
104
|
+
statSync,
|
|
105
|
+
accessSync,
|
|
106
|
+
W_OK: fsConstants.W_OK,
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'DISK',
|
|
111
|
+
run: async () => probeDisk(ctx, {
|
|
112
|
+
getFreeBytes: (home) => getFreeBytesViaDf(home),
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'NODE',
|
|
117
|
+
run: async () => probeNode({ version: process.version }),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'PNPM',
|
|
121
|
+
run: async () => probePnpm({
|
|
122
|
+
resolveVersion: () => execFileSync('pnpm', ['--version'], {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
timeout: 2_000,
|
|
125
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
126
|
+
}).trim(),
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'GIT',
|
|
131
|
+
run: async () => probeGit(ctx, {
|
|
132
|
+
resolveVersion: () => execFileSync('git', ['--version'], {
|
|
133
|
+
encoding: 'utf8',
|
|
134
|
+
timeout: 2_000,
|
|
135
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
136
|
+
}).trim(),
|
|
137
|
+
isInWorkTree: (cwd) => {
|
|
138
|
+
try {
|
|
139
|
+
const result = execFileSync('git', ['-C', cwd, 'rev-parse', '--is-inside-work-tree'], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
timeout: 2_000,
|
|
142
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
143
|
+
}).trim();
|
|
144
|
+
return result === 'true';
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
resolveHeadSha: (cwd) => {
|
|
151
|
+
try {
|
|
152
|
+
return execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
timeout: 2_000,
|
|
155
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
156
|
+
}).trim();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
resolveRoot: (cwd) => {
|
|
163
|
+
try {
|
|
164
|
+
return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
165
|
+
encoding: 'utf8',
|
|
166
|
+
timeout: 2_000,
|
|
167
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
168
|
+
}).trim();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'MCP SERVERS',
|
|
178
|
+
run: async () => probeMcpSafely(ctx),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'CONFIG',
|
|
182
|
+
run: async () => probeConfig(ctx, {
|
|
183
|
+
existsSync,
|
|
184
|
+
readFileSync: (p, encoding) => readFileSync(p, encoding),
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'SESSION',
|
|
189
|
+
run: async () => probeSession(ctx, {
|
|
190
|
+
existsSync,
|
|
191
|
+
statSync,
|
|
192
|
+
readFileSync: (p, encoding) => readFileSync(p, encoding),
|
|
193
|
+
}, {
|
|
194
|
+
now,
|
|
195
|
+
...(options.liveSessionId ? { liveSessionId: options.liveSessionId } : {}),
|
|
196
|
+
}),
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
return probes;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Run the full doctor sweep + emit the output via the supplied
|
|
203
|
+
* writeOutput sink. Returns the report so REPL callers can route it
|
|
204
|
+
* к the Ink renderer instead of the plain-text fallback.
|
|
205
|
+
*/
|
|
206
|
+
export async function runDoctorCommand(ctx) {
|
|
207
|
+
const probeCtx = {
|
|
208
|
+
cwd: ctx.cwd,
|
|
209
|
+
home: ctx.home,
|
|
210
|
+
env: ctx.env,
|
|
211
|
+
};
|
|
212
|
+
const probes = buildDefaultProbes(probeCtx, {
|
|
213
|
+
...(ctx.liveSessionId ? { liveSessionId: ctx.liveSessionId } : {}),
|
|
214
|
+
});
|
|
215
|
+
const report = await runProbes(probes);
|
|
216
|
+
// Defensive recompute: even though runProbes already computed the
|
|
217
|
+
// overall + counts, recomputing here documents the invariant for the
|
|
218
|
+
// reader and gives the JSON envelope a single source of truth.
|
|
219
|
+
const overall = computeOverall(report.probes);
|
|
220
|
+
const counts = countProbes(report.probes);
|
|
221
|
+
const envelope = {
|
|
222
|
+
command: 'doctor',
|
|
223
|
+
overall,
|
|
224
|
+
counts,
|
|
225
|
+
durationMs: report.durationMs,
|
|
226
|
+
probes: report.probes,
|
|
227
|
+
meta: {
|
|
228
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
229
|
+
nodeVersion: process.version,
|
|
230
|
+
cwd: ctx.cwd,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
const text = renderDoctorTable(envelope);
|
|
234
|
+
ctx.writeOutput(envelope, text);
|
|
235
|
+
process.exitCode = exitCodeFor(overall);
|
|
236
|
+
return { ...report, overall, counts };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Plain-text table renderer. Mirrors the layout from the leak-parity
|
|
240
|
+
* spec but is intentionally column-light (3 columns: NAME / STATUS /
|
|
241
|
+
* DETAIL) so it composes well in narrow terminals without dragging
|
|
242
|
+
* a layout library into the CLI hot path. The Ink TUI renderer in
|
|
243
|
+
* `tui/doctor-table.tsx` is the colour-aware variant used inside the
|
|
244
|
+
* REPL.
|
|
245
|
+
*/
|
|
246
|
+
export function renderDoctorTable(envelope) {
|
|
247
|
+
const NAME_WIDTH = Math.max('NAME'.length, ...envelope.probes.map((row) => row.name.length));
|
|
248
|
+
const STATUS_WIDTH = Math.max('STATUS'.length, ...envelope.probes.map((row) => row.status.length));
|
|
249
|
+
const lines = [];
|
|
250
|
+
lines.push('Pugi Doctor — environment health report');
|
|
251
|
+
lines.push('='.repeat(50));
|
|
252
|
+
lines.push('');
|
|
253
|
+
for (const row of envelope.probes) {
|
|
254
|
+
const namePart = row.name.padEnd(NAME_WIDTH, ' ');
|
|
255
|
+
const statusPart = row.status.toUpperCase().padEnd(STATUS_WIDTH, ' ');
|
|
256
|
+
const latencyPart = typeof row.latencyMs === 'number' ? ` (${row.latencyMs}ms)` : '';
|
|
257
|
+
lines.push(`${namePart} ${statusPart} ${row.detail}${latencyPart}`);
|
|
258
|
+
if (row.remediation && (row.status === 'warn' || row.status === 'error')) {
|
|
259
|
+
lines.push(`${' '.repeat(NAME_WIDTH + STATUS_WIDTH + 4)}→ ${row.remediation}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
lines.push('');
|
|
263
|
+
const { ok, warn, error: errorCount, skipped } = envelope.counts;
|
|
264
|
+
const summary = envelope.overall === 'healthy'
|
|
265
|
+
? 'HEALTHY'
|
|
266
|
+
: envelope.overall === 'warning'
|
|
267
|
+
? 'WARNINGS'
|
|
268
|
+
: 'ERRORS';
|
|
269
|
+
lines.push(`${errorCount} error(s), ${warn} warning(s), ${ok} ok, ${skipped} skipped. Overall: ${summary}`);
|
|
270
|
+
lines.push(`CLI ${envelope.meta.cliVersion} Node ${envelope.meta.nodeVersion} cwd ${envelope.meta.cwd}`);
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Wrap the MCP probe in a dynamic import + try/catch so a missing
|
|
275
|
+
* sibling L13 surface (or a schema mismatch in `core/mcp/registry`)
|
|
276
|
+
* degrades the row к `skipped` instead of breaking the entire sweep.
|
|
277
|
+
* The probe-runner already isolates throws into `error` rows; this
|
|
278
|
+
* wrapper additionally distinguishes "feature not available" from
|
|
279
|
+
* "feature crashed".
|
|
280
|
+
*/
|
|
281
|
+
async function probeMcpSafely(ctx) {
|
|
282
|
+
try {
|
|
283
|
+
const mod = await import('../../core/mcp/registry.js');
|
|
284
|
+
if (typeof mod.loadMcpRegistry !== 'function') {
|
|
285
|
+
return {
|
|
286
|
+
name: 'MCP SERVERS',
|
|
287
|
+
status: 'skipped',
|
|
288
|
+
detail: 'MCP integration not exported by this build',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return await probeMcp(ctx, {
|
|
292
|
+
loadRegistry: (cwd, options) => mod.loadMcpRegistry(cwd, { connect: options.connect ?? false }),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
297
|
+
return {
|
|
298
|
+
name: 'MCP SERVERS',
|
|
299
|
+
status: 'skipped',
|
|
300
|
+
detail: 'MCP integration not available',
|
|
301
|
+
remediation: `Inspection failed: ${message}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Best-effort free-bytes lookup via `df -k <home>`. Parses the second
|
|
307
|
+
* line (header + one data row) and returns the `Available` column ×
|
|
308
|
+
* 1024. Throws on parse failure so the probe surfaces a `warn`
|
|
309
|
+
* instead of a misleading 0-bytes-free verdict.
|
|
310
|
+
*
|
|
311
|
+
* Exported for the spec so we can drive it through a stubbed
|
|
312
|
+
* execFileSync without spawning a real subprocess.
|
|
313
|
+
*/
|
|
314
|
+
export function getFreeBytesViaDf(home) {
|
|
315
|
+
const out = execFileSync('df', ['-k', home], {
|
|
316
|
+
encoding: 'utf8',
|
|
317
|
+
timeout: 2_000,
|
|
318
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
319
|
+
});
|
|
320
|
+
return parseDfOutput(out);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Parse the textual output of `df -k`. Handles both BSD and GNU
|
|
324
|
+
* variants — both emit a `Available` column at index 3 of the data
|
|
325
|
+
* row, with one quirk: long device names wrap к the next line on
|
|
326
|
+
* GNU, so we collapse whitespace + tab newlines first.
|
|
327
|
+
*/
|
|
328
|
+
export function parseDfOutput(out) {
|
|
329
|
+
// Collapse multi-line device-name wraps into a single logical row.
|
|
330
|
+
const collapsed = out.replace(/\n\s+/g, ' ');
|
|
331
|
+
const lines = collapsed
|
|
332
|
+
.split('\n')
|
|
333
|
+
.map((line) => line.trim())
|
|
334
|
+
.filter((line) => line.length > 0);
|
|
335
|
+
if (lines.length < 2) {
|
|
336
|
+
throw new Error(`df output too short: ${JSON.stringify(out.slice(0, 64))}`);
|
|
337
|
+
}
|
|
338
|
+
const data = lines[1].split(/\s+/);
|
|
339
|
+
// Schema: Filesystem 1K-blocks Used Available Capacity Mounted-on
|
|
340
|
+
const availableField = data[3];
|
|
341
|
+
if (!availableField) {
|
|
342
|
+
throw new Error(`df output missing Available column: ${JSON.stringify(lines[1])}`);
|
|
343
|
+
}
|
|
344
|
+
const value = Number(availableField);
|
|
345
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
346
|
+
throw new Error(`df Available column not numeric: ${availableField}`);
|
|
347
|
+
}
|
|
348
|
+
return value * 1024;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Default home dir resolver. Centralised so the CLI handler can call
|
|
352
|
+
* `runDoctorCommand` without re-importing `os.homedir` everywhere.
|
|
353
|
+
*/
|
|
354
|
+
export function defaultHome() {
|
|
355
|
+
return homedir();
|
|
356
|
+
}
|
|
357
|
+
//# sourceMappingURL=doctor.js.map
|