gsd-pi 2.31.2-dev.c8d7e03 → 2.32.0-dev.1e39869
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/resources/extensions/gsd/auto-start.ts +4 -2
- package/dist/resources/extensions/gsd/commands.ts +19 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
- package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
- package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
- package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
- package/dist/resources/extensions/gsd/doctor.ts +6 -0
- package/dist/resources/extensions/gsd/health-widget.ts +167 -0
- package/dist/resources/extensions/gsd/index.ts +6 -0
- package/dist/resources/extensions/gsd/progress-score.ts +273 -0
- package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
- package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
- package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
- package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
- package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
- package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-start.ts +4 -2
- package/src/resources/extensions/gsd/commands.ts +19 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
- package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
- package/src/resources/extensions/gsd/doctor-types.ts +14 -1
- package/src/resources/extensions/gsd/doctor.ts +6 -0
- package/src/resources/extensions/gsd/health-widget.ts +167 -0
- package/src/resources/extensions/gsd/index.ts +6 -0
- package/src/resources/extensions/gsd/progress-score.ts +273 -0
- package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
- package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
- package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
- package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
- package/src/resources/extensions/gsd/visualizer-views.ts +54 -0
|
@@ -131,9 +131,11 @@ export async function bootstrapAutoSession(
|
|
|
131
131
|
// Initialize GitServiceImpl
|
|
132
132
|
s.gitService = createGitService(s.basePath);
|
|
133
133
|
|
|
134
|
-
// Check for crash from previous session (use both old and new lock data)
|
|
134
|
+
// Check for crash from previous session (use both old and new lock data).
|
|
135
|
+
// Skip if the lock PID matches this process — acquireSessionLock() writes
|
|
136
|
+
// to the same auto.lock file before this check, so we'd always false-positive.
|
|
135
137
|
const crashLock = readCrashLock(base);
|
|
136
|
-
if (crashLock) {
|
|
138
|
+
if (crashLock && crashLock.pid !== process.pid) {
|
|
137
139
|
// We already hold the session lock, so no concurrent session is running.
|
|
138
140
|
// The crash lock is from a dead process — recover context from it.
|
|
139
141
|
const recoveredMid = crashLock.unitId.split("/")[0];
|
|
@@ -44,6 +44,8 @@ import { handleConfig } from "./commands-config.js";
|
|
|
44
44
|
import { handleInspect } from "./commands-inspect.js";
|
|
45
45
|
import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
|
|
46
46
|
import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
|
|
47
|
+
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
|
|
48
|
+
import { runEnvironmentChecks } from "./doctor-environment.js";
|
|
47
49
|
import { handleLogs } from "./commands-logs.js";
|
|
48
50
|
import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
|
|
49
51
|
|
|
@@ -1068,6 +1070,11 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<
|
|
|
1068
1070
|
function formatTextStatus(state: GSDState): string {
|
|
1069
1071
|
const lines: string[] = ["GSD Status\n"];
|
|
1070
1072
|
|
|
1073
|
+
// Progress score — traffic light (#1221)
|
|
1074
|
+
const progressScore = computeProgressScore();
|
|
1075
|
+
lines.push(formatProgressLine(progressScore));
|
|
1076
|
+
lines.push("");
|
|
1077
|
+
|
|
1071
1078
|
// Phase
|
|
1072
1079
|
lines.push(`Phase: ${state.phase}`);
|
|
1073
1080
|
|
|
@@ -1114,5 +1121,17 @@ function formatTextStatus(state: GSDState): string {
|
|
|
1114
1121
|
}
|
|
1115
1122
|
}
|
|
1116
1123
|
|
|
1124
|
+
// Environment health (#1221)
|
|
1125
|
+
const envResults = runEnvironmentChecks(projectRoot());
|
|
1126
|
+
const envIssues = envResults.filter(r => r.status !== "ok");
|
|
1127
|
+
if (envIssues.length > 0) {
|
|
1128
|
+
lines.push("");
|
|
1129
|
+
lines.push("Environment:");
|
|
1130
|
+
for (const r of envIssues) {
|
|
1131
|
+
const icon = r.status === "error" ? "✗" : "⚠";
|
|
1132
|
+
lines.push(` ${icon} ${r.message}`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1117
1136
|
return lines.join("\n");
|
|
1118
1137
|
}
|
|
@@ -23,6 +23,8 @@ import { getActiveWorktreeName } from "./worktree-command.js";
|
|
|
23
23
|
import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
|
|
24
24
|
import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
|
|
25
25
|
import { estimateTimeRemaining } from "./auto-dashboard.js";
|
|
26
|
+
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
|
|
27
|
+
import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
|
|
26
28
|
|
|
27
29
|
function unitLabel(type: string): string {
|
|
28
30
|
switch (type) {
|
|
@@ -310,6 +312,15 @@ export class GSDDashboardOverlay {
|
|
|
310
312
|
elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`);
|
|
311
313
|
}
|
|
312
314
|
lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth)));
|
|
315
|
+
|
|
316
|
+
// Progress score — traffic light indicator (#1221)
|
|
317
|
+
if (this.dashData.active || this.dashData.paused) {
|
|
318
|
+
const progressScore = computeProgressScore();
|
|
319
|
+
const progressIcon = progressScore.level === "green" ? th.fg("success", "●")
|
|
320
|
+
: progressScore.level === "yellow" ? th.fg("warning", "●")
|
|
321
|
+
: th.fg("error", "●");
|
|
322
|
+
lines.push(row(`${progressIcon} ${th.fg("text", progressScore.summary)}`));
|
|
323
|
+
}
|
|
313
324
|
lines.push(blank());
|
|
314
325
|
|
|
315
326
|
if (this.dashData.currentUnit) {
|
|
@@ -579,6 +590,23 @@ export class GSDDashboardOverlay {
|
|
|
579
590
|
}
|
|
580
591
|
}
|
|
581
592
|
|
|
593
|
+
// Environment health section (#1221) — only show issues
|
|
594
|
+
const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
|
|
595
|
+
const envIssues = envResults.filter(r => r.status !== "ok");
|
|
596
|
+
if (envIssues.length > 0) {
|
|
597
|
+
lines.push(blank());
|
|
598
|
+
lines.push(hr());
|
|
599
|
+
lines.push(row(th.fg("text", th.bold("Environment"))));
|
|
600
|
+
lines.push(blank());
|
|
601
|
+
for (const r of envIssues) {
|
|
602
|
+
const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠");
|
|
603
|
+
lines.push(row(` ${icon} ${th.fg("text", r.message)}`));
|
|
604
|
+
if (r.detail) {
|
|
605
|
+
lines.push(row(th.fg("dim", ` ${r.detail}`)));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
582
610
|
lines.push(blank());
|
|
583
611
|
lines.push(hr());
|
|
584
612
|
lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close")));
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD Doctor — Environment Health Checks (#1221)
|
|
3
|
+
*
|
|
4
|
+
* Deterministic checks for environment readiness that prevent the model
|
|
5
|
+
* from spinning its wheels on missing tools, port conflicts, stale
|
|
6
|
+
* dependencies, and other infrastructure issues.
|
|
7
|
+
*
|
|
8
|
+
* These checks complement the existing git/runtime health checks and
|
|
9
|
+
* integrate into the doctor pipeline via checkEnvironmentHealth().
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
|
17
|
+
|
|
18
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface EnvironmentCheckResult {
|
|
21
|
+
name: string;
|
|
22
|
+
status: "ok" | "warning" | "error";
|
|
23
|
+
message: string;
|
|
24
|
+
detail?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Default dev server ports to scan for conflicts. */
|
|
30
|
+
const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888];
|
|
31
|
+
|
|
32
|
+
/** Minimum free disk space in bytes (500MB). */
|
|
33
|
+
const MIN_DISK_BYTES = 500 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Timeout for external commands (ms). */
|
|
36
|
+
const CMD_TIMEOUT = 5_000;
|
|
37
|
+
|
|
38
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function tryExec(cmd: string, cwd: string): string | null {
|
|
41
|
+
try {
|
|
42
|
+
return execSync(cmd, {
|
|
43
|
+
cwd,
|
|
44
|
+
timeout: CMD_TIMEOUT,
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
encoding: "utf-8",
|
|
47
|
+
}).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function commandExists(name: string, cwd: string): boolean {
|
|
54
|
+
const whichCmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
|
|
55
|
+
return tryExec(whichCmd, cwd) !== null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Individual Checks ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check that Node.js version meets the project's engines requirement.
|
|
62
|
+
*/
|
|
63
|
+
function checkNodeVersion(basePath: string): EnvironmentCheckResult | null {
|
|
64
|
+
const pkgPath = join(basePath, "package.json");
|
|
65
|
+
if (!existsSync(pkgPath)) return null;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
69
|
+
const required = pkg.engines?.node;
|
|
70
|
+
if (!required) return null;
|
|
71
|
+
|
|
72
|
+
const currentVersion = tryExec("node --version", basePath);
|
|
73
|
+
if (!currentVersion) {
|
|
74
|
+
return { name: "node_version", status: "error", message: "Node.js not found in PATH" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Parse semver requirement (handles >=X.Y.Z format)
|
|
78
|
+
const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/);
|
|
79
|
+
if (!reqMatch) return null;
|
|
80
|
+
|
|
81
|
+
const reqMajor = parseInt(reqMatch[1], 10);
|
|
82
|
+
const reqMinor = parseInt(reqMatch[2] ?? "0", 10);
|
|
83
|
+
|
|
84
|
+
const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/);
|
|
85
|
+
if (!curMatch) return null;
|
|
86
|
+
|
|
87
|
+
const curMajor = parseInt(curMatch[1], 10);
|
|
88
|
+
const curMinor = parseInt(curMatch[2], 10);
|
|
89
|
+
|
|
90
|
+
if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) {
|
|
91
|
+
return {
|
|
92
|
+
name: "node_version",
|
|
93
|
+
status: "warning",
|
|
94
|
+
message: `Node.js ${currentVersion} does not meet requirement "${required}"`,
|
|
95
|
+
detail: `Current: ${currentVersion}, Required: ${required}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { name: "node_version", status: "ok", message: `Node.js ${currentVersion}` };
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if node_modules exists and is not stale vs the lockfile.
|
|
107
|
+
*/
|
|
108
|
+
function checkDependenciesInstalled(basePath: string): EnvironmentCheckResult | null {
|
|
109
|
+
const pkgPath = join(basePath, "package.json");
|
|
110
|
+
if (!existsSync(pkgPath)) return null;
|
|
111
|
+
|
|
112
|
+
const nodeModules = join(basePath, "node_modules");
|
|
113
|
+
if (!existsSync(nodeModules)) {
|
|
114
|
+
return {
|
|
115
|
+
name: "dependencies",
|
|
116
|
+
status: "error",
|
|
117
|
+
message: "node_modules missing — run npm install",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check if lockfile is newer than node_modules
|
|
122
|
+
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
|
123
|
+
for (const lockfile of lockfiles) {
|
|
124
|
+
const lockPath = join(basePath, lockfile);
|
|
125
|
+
if (!existsSync(lockPath)) continue;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const lockMtime = statSync(lockPath).mtimeMs;
|
|
129
|
+
const nmMtime = statSync(nodeModules).mtimeMs;
|
|
130
|
+
|
|
131
|
+
if (lockMtime > nmMtime) {
|
|
132
|
+
return {
|
|
133
|
+
name: "dependencies",
|
|
134
|
+
status: "warning",
|
|
135
|
+
message: `${lockfile} is newer than node_modules — dependencies may be stale`,
|
|
136
|
+
detail: `Run npm install / yarn / pnpm install to update`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// stat failed — skip
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { name: "dependencies", status: "ok", message: "Dependencies installed" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check for .env.example files without corresponding .env files.
|
|
149
|
+
*/
|
|
150
|
+
function checkEnvFiles(basePath: string): EnvironmentCheckResult | null {
|
|
151
|
+
const examplePath = join(basePath, ".env.example");
|
|
152
|
+
if (!existsSync(examplePath)) return null;
|
|
153
|
+
|
|
154
|
+
const envPath = join(basePath, ".env");
|
|
155
|
+
const envLocalPath = join(basePath, ".env.local");
|
|
156
|
+
|
|
157
|
+
if (!existsSync(envPath) && !existsSync(envLocalPath)) {
|
|
158
|
+
return {
|
|
159
|
+
name: "env_file",
|
|
160
|
+
status: "warning",
|
|
161
|
+
message: ".env.example exists but no .env or .env.local found",
|
|
162
|
+
detail: "Copy .env.example to .env and fill in values",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { name: "env_file", status: "ok", message: "Environment file present" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check for port conflicts on common dev server ports.
|
|
171
|
+
* Only checks ports that appear in package.json scripts.
|
|
172
|
+
*/
|
|
173
|
+
function checkPortConflicts(basePath: string): EnvironmentCheckResult[] {
|
|
174
|
+
// Only run on macOS/Linux — lsof is not available on Windows
|
|
175
|
+
if (process.platform === "win32") return [];
|
|
176
|
+
|
|
177
|
+
const results: EnvironmentCheckResult[] = [];
|
|
178
|
+
|
|
179
|
+
// Try to detect ports from package.json scripts
|
|
180
|
+
const portsToCheck = new Set<number>();
|
|
181
|
+
const pkgPath = join(basePath, "package.json");
|
|
182
|
+
|
|
183
|
+
if (existsSync(pkgPath)) {
|
|
184
|
+
try {
|
|
185
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
186
|
+
const scripts = pkg.scripts ?? {};
|
|
187
|
+
const scriptText = Object.values(scripts).join(" ");
|
|
188
|
+
|
|
189
|
+
// Look for --port NNNN, -p NNNN, PORT=NNNN, :NNNN patterns
|
|
190
|
+
const portMatches = scriptText.matchAll(/(?:--port\s+|(?:^|[^a-z])PORT[=:]\s*|-p\s+|:)(\d{4,5})\b/gi);
|
|
191
|
+
for (const m of portMatches) {
|
|
192
|
+
const port = parseInt(m[1], 10);
|
|
193
|
+
if (port >= 1024 && port <= 65535) portsToCheck.add(port);
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// parse failed — use defaults
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// If no ports found in scripts, check common defaults
|
|
201
|
+
if (portsToCheck.size === 0) {
|
|
202
|
+
for (const p of DEFAULT_DEV_PORTS) portsToCheck.add(p);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const port of portsToCheck) {
|
|
206
|
+
const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath);
|
|
207
|
+
if (result && result.length > 0) {
|
|
208
|
+
// Get process name
|
|
209
|
+
const nameResult = tryExec(`lsof -i :${port} -sTCP:LISTEN -Fp | head -2`, basePath);
|
|
210
|
+
const processName = nameResult?.match(/p(\d+)\n?c?(.+)?/)?.[2] ?? "unknown";
|
|
211
|
+
|
|
212
|
+
results.push({
|
|
213
|
+
name: "port_conflict",
|
|
214
|
+
status: "warning",
|
|
215
|
+
message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`,
|
|
216
|
+
detail: `Kill the process or use a different port`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check available disk space on the working directory partition.
|
|
226
|
+
*/
|
|
227
|
+
function checkDiskSpace(basePath: string): EnvironmentCheckResult | null {
|
|
228
|
+
// Only run on macOS/Linux
|
|
229
|
+
if (process.platform === "win32") return null;
|
|
230
|
+
|
|
231
|
+
const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath);
|
|
232
|
+
if (!dfOutput) return null;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// df output: filesystem blocks used avail capacity mount
|
|
236
|
+
const parts = dfOutput.split(/\s+/);
|
|
237
|
+
const availKB = parseInt(parts[3], 10);
|
|
238
|
+
if (isNaN(availKB)) return null;
|
|
239
|
+
|
|
240
|
+
const availBytes = availKB * 1024;
|
|
241
|
+
const availMB = Math.round(availBytes / (1024 * 1024));
|
|
242
|
+
const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1);
|
|
243
|
+
|
|
244
|
+
if (availBytes < MIN_DISK_BYTES) {
|
|
245
|
+
return {
|
|
246
|
+
name: "disk_space",
|
|
247
|
+
status: "error",
|
|
248
|
+
message: `Low disk space: ${availMB}MB free`,
|
|
249
|
+
detail: `Free up space — builds and git operations may fail`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (availBytes < MIN_DISK_BYTES * 4) {
|
|
254
|
+
return {
|
|
255
|
+
name: "disk_space",
|
|
256
|
+
status: "warning",
|
|
257
|
+
message: `Disk space getting low: ${availGB}GB free`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { name: "disk_space", status: "ok", message: `${availGB}GB free` };
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Check if Docker is available when project has a Dockerfile.
|
|
269
|
+
*/
|
|
270
|
+
function checkDocker(basePath: string): EnvironmentCheckResult | null {
|
|
271
|
+
const hasDockerfile = existsSync(join(basePath, "Dockerfile")) ||
|
|
272
|
+
existsSync(join(basePath, "docker-compose.yml")) ||
|
|
273
|
+
existsSync(join(basePath, "docker-compose.yaml")) ||
|
|
274
|
+
existsSync(join(basePath, "compose.yml")) ||
|
|
275
|
+
existsSync(join(basePath, "compose.yaml"));
|
|
276
|
+
|
|
277
|
+
if (!hasDockerfile) return null;
|
|
278
|
+
|
|
279
|
+
if (!commandExists("docker", basePath)) {
|
|
280
|
+
return {
|
|
281
|
+
name: "docker",
|
|
282
|
+
status: "warning",
|
|
283
|
+
message: "Project has Docker files but docker is not installed",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath);
|
|
288
|
+
if (!info) {
|
|
289
|
+
return {
|
|
290
|
+
name: "docker",
|
|
291
|
+
status: "warning",
|
|
292
|
+
message: "Docker is installed but daemon is not running",
|
|
293
|
+
detail: "Start Docker Desktop or the docker daemon",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { name: "docker", status: "ok", message: `Docker ${info}` };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check for common project tools that should be available.
|
|
302
|
+
*/
|
|
303
|
+
function checkProjectTools(basePath: string): EnvironmentCheckResult[] {
|
|
304
|
+
const results: EnvironmentCheckResult[] = [];
|
|
305
|
+
const pkgPath = join(basePath, "package.json");
|
|
306
|
+
|
|
307
|
+
if (!existsSync(pkgPath)) return results;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
311
|
+
const allDeps = {
|
|
312
|
+
...(pkg.dependencies ?? {}),
|
|
313
|
+
...(pkg.devDependencies ?? {}),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Check for package manager
|
|
317
|
+
const packageManager = pkg.packageManager;
|
|
318
|
+
if (packageManager) {
|
|
319
|
+
const managerName = packageManager.split("@")[0];
|
|
320
|
+
if (managerName && managerName !== "npm" && !commandExists(managerName, basePath)) {
|
|
321
|
+
results.push({
|
|
322
|
+
name: "package_manager",
|
|
323
|
+
status: "warning",
|
|
324
|
+
message: `Project requires ${managerName} but it's not installed`,
|
|
325
|
+
detail: `Install with: npm install -g ${managerName}`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check for TypeScript if it's a dependency
|
|
331
|
+
if (allDeps["typescript"] && !existsSync(join(basePath, "node_modules", ".bin", "tsc"))) {
|
|
332
|
+
results.push({
|
|
333
|
+
name: "typescript",
|
|
334
|
+
status: "warning",
|
|
335
|
+
message: "TypeScript is a dependency but tsc is not available (run npm install)",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check for Python if pyproject.toml or requirements.txt exists
|
|
340
|
+
if (existsSync(join(basePath, "pyproject.toml")) || existsSync(join(basePath, "requirements.txt"))) {
|
|
341
|
+
if (!commandExists("python3", basePath) && !commandExists("python", basePath)) {
|
|
342
|
+
results.push({
|
|
343
|
+
name: "python",
|
|
344
|
+
status: "warning",
|
|
345
|
+
message: "Project has Python config but python is not installed",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check for Rust if Cargo.toml exists
|
|
351
|
+
if (existsSync(join(basePath, "Cargo.toml"))) {
|
|
352
|
+
if (!commandExists("cargo", basePath)) {
|
|
353
|
+
results.push({
|
|
354
|
+
name: "cargo",
|
|
355
|
+
status: "warning",
|
|
356
|
+
message: "Project has Cargo.toml but cargo is not installed",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Check for Go if go.mod exists
|
|
362
|
+
if (existsSync(join(basePath, "go.mod"))) {
|
|
363
|
+
if (!commandExists("go", basePath)) {
|
|
364
|
+
results.push({
|
|
365
|
+
name: "go",
|
|
366
|
+
status: "warning",
|
|
367
|
+
message: "Project has go.mod but go is not installed",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// parse failed — skip
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return results;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Check git remote reachability.
|
|
380
|
+
*/
|
|
381
|
+
function checkGitRemote(basePath: string): EnvironmentCheckResult | null {
|
|
382
|
+
// Only check if it's a git repo with a remote
|
|
383
|
+
const remote = tryExec("git remote get-url origin", basePath);
|
|
384
|
+
if (!remote) return null;
|
|
385
|
+
|
|
386
|
+
// Quick connectivity check with short timeout
|
|
387
|
+
const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath);
|
|
388
|
+
if (result === null) {
|
|
389
|
+
return {
|
|
390
|
+
name: "git_remote",
|
|
391
|
+
status: "warning",
|
|
392
|
+
message: "Git remote 'origin' is unreachable",
|
|
393
|
+
detail: `Remote: ${remote}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { name: "git_remote", status: "ok", message: "Git remote reachable" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Run all environment health checks. Returns structured results for
|
|
404
|
+
* integration with the doctor pipeline.
|
|
405
|
+
*/
|
|
406
|
+
export function runEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
|
|
407
|
+
const results: EnvironmentCheckResult[] = [];
|
|
408
|
+
|
|
409
|
+
const nodeCheck = checkNodeVersion(basePath);
|
|
410
|
+
if (nodeCheck) results.push(nodeCheck);
|
|
411
|
+
|
|
412
|
+
const depsCheck = checkDependenciesInstalled(basePath);
|
|
413
|
+
if (depsCheck) results.push(depsCheck);
|
|
414
|
+
|
|
415
|
+
const envCheck = checkEnvFiles(basePath);
|
|
416
|
+
if (envCheck) results.push(envCheck);
|
|
417
|
+
|
|
418
|
+
results.push(...checkPortConflicts(basePath));
|
|
419
|
+
|
|
420
|
+
const diskCheck = checkDiskSpace(basePath);
|
|
421
|
+
if (diskCheck) results.push(diskCheck);
|
|
422
|
+
|
|
423
|
+
const dockerCheck = checkDocker(basePath);
|
|
424
|
+
if (dockerCheck) results.push(dockerCheck);
|
|
425
|
+
|
|
426
|
+
results.push(...checkProjectTools(basePath));
|
|
427
|
+
|
|
428
|
+
// Git remote check can be slow — only run on explicit doctor invocation
|
|
429
|
+
// (not on pre-dispatch gate)
|
|
430
|
+
|
|
431
|
+
return results;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run environment checks with git remote check included.
|
|
436
|
+
* Use this for explicit /gsd doctor invocations, not pre-dispatch gates.
|
|
437
|
+
*/
|
|
438
|
+
export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
|
|
439
|
+
const results = runEnvironmentChecks(basePath);
|
|
440
|
+
|
|
441
|
+
const remoteCheck = checkGitRemote(basePath);
|
|
442
|
+
if (remoteCheck) results.push(remoteCheck);
|
|
443
|
+
|
|
444
|
+
return results;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Convert environment check results to DoctorIssue format for the doctor pipeline.
|
|
449
|
+
*/
|
|
450
|
+
export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult[]): DoctorIssue[] {
|
|
451
|
+
return results
|
|
452
|
+
.filter(r => r.status !== "ok")
|
|
453
|
+
.map(r => ({
|
|
454
|
+
severity: r.status === "error" ? "error" as const : "warning" as const,
|
|
455
|
+
code: `env_${r.name}` as DoctorIssueCode,
|
|
456
|
+
scope: "project" as const,
|
|
457
|
+
unitId: "environment",
|
|
458
|
+
message: r.detail ? `${r.message} — ${r.detail}` : r.message,
|
|
459
|
+
fixable: false,
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Integration point for the doctor pipeline. Runs environment checks
|
|
465
|
+
* and appends issues to the provided array.
|
|
466
|
+
*/
|
|
467
|
+
export async function checkEnvironmentHealth(
|
|
468
|
+
basePath: string,
|
|
469
|
+
issues: DoctorIssue[],
|
|
470
|
+
options?: { includeRemote?: boolean },
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
const results = options?.includeRemote
|
|
473
|
+
? runFullEnvironmentChecks(basePath)
|
|
474
|
+
: runEnvironmentChecks(basePath);
|
|
475
|
+
|
|
476
|
+
issues.push(...environmentResultsToDoctorIssues(results));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Format environment check results for display.
|
|
481
|
+
*/
|
|
482
|
+
export function formatEnvironmentReport(results: EnvironmentCheckResult[]): string {
|
|
483
|
+
if (results.length === 0) return "No environment checks applicable.";
|
|
484
|
+
|
|
485
|
+
const lines: string[] = [];
|
|
486
|
+
lines.push("Environment Health:");
|
|
487
|
+
|
|
488
|
+
for (const r of results) {
|
|
489
|
+
const icon = r.status === "ok" ? "\u2705" : r.status === "warning" ? "\u26A0\uFE0F" : "\uD83D\uDED1";
|
|
490
|
+
lines.push(` ${icon} ${r.message}`);
|
|
491
|
+
if (r.detail && r.status !== "ok") {
|
|
492
|
+
lines.push(` ${r.detail}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return lines.join("\n");
|
|
497
|
+
}
|