heyspex 0.1.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/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dev-server-status — ground-truth answer to "is the spex-console dev server
|
|
4
|
+
* running, and on what port(s)?"
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the only prior signal was `.heyspex/dev-server.lock.json`,
|
|
7
|
+
* and that file can LIE — a stale lock (the launcher died uncleanly), an
|
|
8
|
+
* untracked server (someone ran bare `next dev` with no wrapper, so no lock),
|
|
9
|
+
* a port conflict, or two servers at once. An agent that trusts the file can
|
|
10
|
+
* start a database migration "while the server is down" when it is actually
|
|
11
|
+
* up — which strands the running server's in-memory schema and forces a
|
|
12
|
+
* restart. (That incident is what this capability prevents.)
|
|
13
|
+
*
|
|
14
|
+
* The fix: derive truth from the OS, never from the lockfile alone.
|
|
15
|
+
* 1. HTTP-probe candidate ports — liveness is the authoritative "is it up?".
|
|
16
|
+
* 2. OS-scan the same ports (Get-NetTCPConnection on Windows, lsof/ss on
|
|
17
|
+
* Unix — the read-only half of the kill-ports skill) for pid/name.
|
|
18
|
+
* 3. Read the lockfile + .heyspex/console-url ONLY as hints to attribute a
|
|
19
|
+
* pid/url and to detect disagreement.
|
|
20
|
+
* 4. Emit explicit discrepancy flags (stale_lock, untracked_server,
|
|
21
|
+
* multiple_servers, port_mismatch) so every way the file could be wrong
|
|
22
|
+
* is surfaced, not hidden.
|
|
23
|
+
*
|
|
24
|
+
* The `running` verdict comes from the HTTP probe, so the answer is grounded
|
|
25
|
+
* in what is actually listening — it cannot be a false positive off a stale
|
|
26
|
+
* file, nor a false negative when an untracked server is up.
|
|
27
|
+
*
|
|
28
|
+
* Three faces share this core (per the "capability = core → MCP + CLI" rule):
|
|
29
|
+
* - MCP tool heyspex_dev_server_status (vendor/heyspex-mcp/cli.mjs)
|
|
30
|
+
* - hs CLI hs server status (bin/hs.mjs → runs this file)
|
|
31
|
+
* - PreToolUse hook reminder (scripts/claude-hooks/…)
|
|
32
|
+
*
|
|
33
|
+
* Run directly: node scripts/dev-server-status.mjs [--json]
|
|
34
|
+
* Import: import { getDevServerStatus } from './dev-server-status.mjs'
|
|
35
|
+
*/
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import http from 'node:http';
|
|
39
|
+
import { spawnSync } from 'node:child_process';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
|
|
42
|
+
// `/api/version` is the spex-console liveness endpoint (cheap JSON, no page
|
|
43
|
+
// render). Probing `/` false-negatives during Next dev JIT compiles — the full
|
|
44
|
+
// app shell can exceed 2s while API routes the UI already uses keep returning
|
|
45
|
+
// 200. See HS-EPIC-OBSV.
|
|
46
|
+
const HTTP_PROBE_TIMEOUT_MS = 4000;
|
|
47
|
+
const PROBE_PATH = '/api/version';
|
|
48
|
+
// HS-09JU: a single 4s probe can miss a live-but-cold-compiling server (Next
|
|
49
|
+
// dev JIT-compiling the /api/version route) and false-flag it dead
|
|
50
|
+
// (running:false, stale_lock) — the exact false positive that made agents
|
|
51
|
+
// start a duplicate server against an already-live one. One retry after a
|
|
52
|
+
// backoff is enough to ride out a cold compile without materially slowing
|
|
53
|
+
// down the common "genuinely nothing there" case (connection refused/reset
|
|
54
|
+
// returns near-instantly, so the retry+delay path is only paid when a real
|
|
55
|
+
// TCP timeout occurred on the first attempt).
|
|
56
|
+
const PROBE_RETRY_ATTEMPTS = 2;
|
|
57
|
+
const PROBE_RETRY_DELAY_MS = 1500;
|
|
58
|
+
// HS-D4O9-DIAG: API routes answering is NOT proof the console is usable — a
|
|
59
|
+
// missing dependency (the incident: @clerk/nextjs declared but not installed)
|
|
60
|
+
// left every PAGE route throwing "Cannot find module" while API handlers kept
|
|
61
|
+
// returning 200, so the API-only probe reported a dead UI as healthy. A SECOND
|
|
62
|
+
// probe against a real page route catches this — the broken layout.tsx sat
|
|
63
|
+
// ABOVE every route, so any page route would have caught the incident. /sign-in
|
|
64
|
+
// specifically because it's the route the HS-D4O9-DIAG fix was verified against
|
|
65
|
+
// (confirmed 200 post-fix; the diagnosis's own AC4 recommendation names it) —
|
|
66
|
+
// prefer a route that renders REAL content over one that may 404 (a 404 still
|
|
67
|
+
// proves the framework compiled/routed, but a 200 is stronger positive
|
|
68
|
+
// evidence). `ok` below still treats any non-5xx as healthy, so an environment
|
|
69
|
+
// where /sign-in genuinely doesn't exist degrades to "route not found", never
|
|
70
|
+
// a false failure. Longer timeout than the API probe — a page render can be
|
|
71
|
+
// slower than a JSON handler, especially on the first post-restart compile.
|
|
72
|
+
const PAGE_PROBE_PATH = '/sign-in';
|
|
73
|
+
const PAGE_PROBE_TIMEOUT_MS = 6000;
|
|
74
|
+
const OS_SCAN_TIMEOUT_MS = 2500;
|
|
75
|
+
// Default candidate ports: Next dev defaults plus a few it bumps to when 3000
|
|
76
|
+
// is taken (3000 → 3001 → … ). Kept small on purpose — a wide range would slow
|
|
77
|
+
// the probe and is rarely needed; the lockfile / console-url ports are added on
|
|
78
|
+
// top so an auto-bumped server is still found.
|
|
79
|
+
const DEFAULT_PORTS = [3000, 3001, 3002, 3003, 3004, 3005];
|
|
80
|
+
|
|
81
|
+
function defaultVaultRoot() {
|
|
82
|
+
// Honor the same override the hs CLI uses; otherwise fall back to this file's
|
|
83
|
+
// own location (it lives at <vaultRoot>/scripts/, so one level up is the root).
|
|
84
|
+
if (process.env.HS_VAULT_ROOT) return process.env.HS_VAULT_ROOT;
|
|
85
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readLockfile(vaultRoot) {
|
|
89
|
+
const file = path.join(vaultRoot, '.heyspex', 'dev-server.lock.json');
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
92
|
+
if (parsed && typeof parsed === 'object') {
|
|
93
|
+
return {
|
|
94
|
+
present: true,
|
|
95
|
+
pid: typeof parsed.pid === 'number' ? parsed.pid : null,
|
|
96
|
+
url: typeof parsed.url === 'string' ? parsed.url : null,
|
|
97
|
+
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : null,
|
|
98
|
+
// Bind host, recorded by ensure-next-dev.mjs. `host` is the interface
|
|
99
|
+
// passed to `next dev -H`; `lan` says whether that interface is
|
|
100
|
+
// reachable from off this machine. null on a lock written by an older
|
|
101
|
+
// launcher — UNKNOWN, which readers must not read as "loopback".
|
|
102
|
+
host: typeof parsed.host === 'string' ? parsed.host : null,
|
|
103
|
+
lan: typeof parsed.lan === 'boolean' ? parsed.lan : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Missing or malformed — no lock.
|
|
108
|
+
}
|
|
109
|
+
return { present: false, pid: null, url: null, startedAt: null, host: null, lan: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readConsoleUrlFile(vaultRoot) {
|
|
113
|
+
for (const name of ['console-url', 'cockpit-url']) { // legacy-cockpit fallback
|
|
114
|
+
try {
|
|
115
|
+
const raw = fs.readFileSync(path.join(vaultRoot, '.heyspex', name), 'utf8').trim();
|
|
116
|
+
if (raw.length > 0) return raw.replace(/\/+$/, '');
|
|
117
|
+
} catch {
|
|
118
|
+
// try next candidate
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Atomically publish the live console origin for MCP + hooks ([[HS-EPIC-PT8A]]).
|
|
126
|
+
* Called by ensure-next-dev on bind and by the MCP transport recovery path when
|
|
127
|
+
* a port scan finds the console on a port the stale discovery file missed.
|
|
128
|
+
*/
|
|
129
|
+
export function writeConsoleUrlFile(vaultRoot, url) {
|
|
130
|
+
const root = vaultRoot || defaultVaultRoot();
|
|
131
|
+
const dir = path.join(root, '.heyspex');
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
const file = path.join(dir, 'console-url');
|
|
134
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
135
|
+
const normalized = String(url).trim().replace(/\/+$/, '');
|
|
136
|
+
fs.writeFileSync(tmp, `${normalized}\n`, 'utf8');
|
|
137
|
+
fs.renameSync(tmp, file);
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(path.join(dir, 'cockpit-url'), `${normalized}\n`, 'utf8'); // legacy-cockpit shadow
|
|
140
|
+
} catch {
|
|
141
|
+
// Best-effort — canonical console-url above is authoritative.
|
|
142
|
+
}
|
|
143
|
+
return normalized;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Signal-0 liveness probe — mirrors pidAlive() in ensure-next-dev.mjs. EPERM
|
|
147
|
+
// means the process exists but is owned by another user, so it is still alive.
|
|
148
|
+
function pidAlive(pid) {
|
|
149
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
150
|
+
try {
|
|
151
|
+
process.kill(pid, 0);
|
|
152
|
+
return true;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return err.code === 'EPERM';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function portFromUrl(url) {
|
|
159
|
+
if (typeof url !== 'string') return null;
|
|
160
|
+
try {
|
|
161
|
+
const u = new URL(url);
|
|
162
|
+
if (u.port) return Number(u.port);
|
|
163
|
+
return u.protocol === 'https:' ? 443 : 80;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function candidatePorts(vaultRoot, override) {
|
|
170
|
+
if (Array.isArray(override) && override.length > 0) {
|
|
171
|
+
return [...new Set(override.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
|
|
172
|
+
}
|
|
173
|
+
const lock = readLockfile(vaultRoot);
|
|
174
|
+
const fromLock = portFromUrl(lock.url);
|
|
175
|
+
const fromConsole = portFromUrl(readConsoleUrlFile(vaultRoot));
|
|
176
|
+
const ports = [...DEFAULT_PORTS];
|
|
177
|
+
if (fromLock) ports.push(fromLock);
|
|
178
|
+
if (fromConsole) ports.push(fromConsole);
|
|
179
|
+
return [...new Set(ports)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- OS-level listener scan (pid attribution; best-effort) -----------------
|
|
183
|
+
|
|
184
|
+
function scanListenersWindows(ports) {
|
|
185
|
+
// Read-only twin of kill-ports.ps1's Get-PortListeners. PowerShell 5.1's
|
|
186
|
+
// ConvertTo-Json emits a bare object for a single row, so we force an array
|
|
187
|
+
// and parse defensively below.
|
|
188
|
+
const portList = ports.join(',');
|
|
189
|
+
const script =
|
|
190
|
+
`$ports=@(${portList});` +
|
|
191
|
+
`$rows=@(Get-NetTCPConnection -State Listen -LocalPort $ports -ErrorAction SilentlyContinue|` +
|
|
192
|
+
`ForEach-Object{$p=Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue;` +
|
|
193
|
+
`[pscustomobject]@{port=[int]$_.LocalPort;pid=[int]$_.OwningProcess;name=if($p){$p.ProcessName}else{''}}});` +
|
|
194
|
+
`ConvertTo-Json -InputObject $rows -Compress`;
|
|
195
|
+
const res = spawnSync(
|
|
196
|
+
'powershell',
|
|
197
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
198
|
+
{ encoding: 'utf8', timeout: OS_SCAN_TIMEOUT_MS },
|
|
199
|
+
);
|
|
200
|
+
if (res.status !== 0 || !res.stdout) return [];
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(res.stdout.trim());
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
208
|
+
return rows
|
|
209
|
+
.filter((r) => r && Number.isInteger(r.port))
|
|
210
|
+
.map((r) => ({ port: r.port, pid: Number.isInteger(r.pid) ? r.pid : null, name: r.name || '' }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function scanListenersUnix(ports) {
|
|
214
|
+
const rows = [];
|
|
215
|
+
const haveLsof = spawnSync('sh', ['-c', 'command -v lsof'], { encoding: 'utf8' }).status === 0;
|
|
216
|
+
for (const port of ports) {
|
|
217
|
+
let pids = [];
|
|
218
|
+
if (haveLsof) {
|
|
219
|
+
const res = spawnSync('lsof', ['-t', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], {
|
|
220
|
+
encoding: 'utf8',
|
|
221
|
+
timeout: OS_SCAN_TIMEOUT_MS,
|
|
222
|
+
});
|
|
223
|
+
if (res.stdout) pids = res.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean);
|
|
224
|
+
} else {
|
|
225
|
+
const res = spawnSync('ss', ['-ltnp', `sport = :${port}`], {
|
|
226
|
+
encoding: 'utf8',
|
|
227
|
+
timeout: OS_SCAN_TIMEOUT_MS,
|
|
228
|
+
});
|
|
229
|
+
if (res.stdout) {
|
|
230
|
+
for (const m of res.stdout.matchAll(/pid=(\d+)/g)) pids.push(Number(m[1]));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const pid of [...new Set(pids)]) {
|
|
234
|
+
let name = '';
|
|
235
|
+
const ps = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' });
|
|
236
|
+
if (ps.stdout) name = ps.stdout.trim();
|
|
237
|
+
rows.push({ port, pid, name });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function scanListeners(ports) {
|
|
244
|
+
try {
|
|
245
|
+
return process.platform === 'win32' ? scanListenersWindows(ports) : scanListenersUnix(ports);
|
|
246
|
+
} catch {
|
|
247
|
+
// OS scan is best-effort — HTTP probe still establishes liveness without it.
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A spex-console dev server (however it was started — wrapper, bare `next
|
|
253
|
+
// dev`, a manual `-p 8080`) is always a `node` process. Filtering the wide
|
|
254
|
+
// scan to this name keeps it from ballooning into probing every unrelated
|
|
255
|
+
// listener on the machine (browsers, Docker, print spooler, …) the way an
|
|
256
|
+
// unfiltered machine-wide scan would.
|
|
257
|
+
function isNodeLikeListenerName(name) {
|
|
258
|
+
return /^node(\.exe)?$/i.test(name || '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- Wide OS scan (no port filter) — HS-09JU ------------------------------
|
|
262
|
+
//
|
|
263
|
+
// `scanListeners(ports)` above only ever confirms ports ALREADY in the
|
|
264
|
+
// candidate set (DEFAULT_PORTS + lockfile/console-url hints). A live console
|
|
265
|
+
// server that Next auto-bumped past 3005, or that someone started with a
|
|
266
|
+
// manual `-p`, is invisible to it — the exact "off-candidate-port live server
|
|
267
|
+
// reported dead" false positive from the diagnosis. This scan has NO port
|
|
268
|
+
// filter, so it sees every listener on the machine, then narrows to node-like
|
|
269
|
+
// process names before returning — the discovered ports get folded into the
|
|
270
|
+
// HTTP probe list by getDevServerStatus, not returned as candidates on their
|
|
271
|
+
// own (an unrelated node process squatting some other port must still fail
|
|
272
|
+
// the API-probe 'console' classification before it counts as a hit).
|
|
273
|
+
function scanWideListenersWindows() {
|
|
274
|
+
const script =
|
|
275
|
+
`$rows=@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue|` +
|
|
276
|
+
`ForEach-Object{$p=Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue;` +
|
|
277
|
+
`[pscustomobject]@{port=[int]$_.LocalPort;pid=[int]$_.OwningProcess;name=if($p){$p.ProcessName}else{''}}});` +
|
|
278
|
+
`ConvertTo-Json -InputObject $rows -Compress`;
|
|
279
|
+
const res = spawnSync(
|
|
280
|
+
'powershell',
|
|
281
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
282
|
+
{ encoding: 'utf8', timeout: OS_SCAN_TIMEOUT_MS },
|
|
283
|
+
);
|
|
284
|
+
if (res.status !== 0 || !res.stdout) return [];
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(res.stdout.trim());
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
292
|
+
return rows
|
|
293
|
+
.filter((r) => r && Number.isInteger(r.port) && isNodeLikeListenerName(r.name))
|
|
294
|
+
.map((r) => ({ port: r.port, pid: Number.isInteger(r.pid) ? r.pid : null, name: r.name || '' }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function scanWideListenersUnix() {
|
|
298
|
+
const haveLsof = spawnSync('sh', ['-c', 'command -v lsof'], { encoding: 'utf8' }).status === 0;
|
|
299
|
+
const rows = [];
|
|
300
|
+
if (haveLsof) {
|
|
301
|
+
const res = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-nP'], {
|
|
302
|
+
encoding: 'utf8',
|
|
303
|
+
timeout: OS_SCAN_TIMEOUT_MS,
|
|
304
|
+
});
|
|
305
|
+
if (!res.stdout) return [];
|
|
306
|
+
for (const line of res.stdout.split('\n').slice(1)) { // header row first
|
|
307
|
+
const cols = line.trim().split(/\s+/);
|
|
308
|
+
if (cols.length < 9) continue;
|
|
309
|
+
const name = cols[0];
|
|
310
|
+
const pid = Number(cols[1]);
|
|
311
|
+
const addr = cols[8];
|
|
312
|
+
const portMatch = addr && addr.match(/:(\d+)$/);
|
|
313
|
+
if (!portMatch || !isNodeLikeListenerName(name)) continue;
|
|
314
|
+
rows.push({ port: Number(portMatch[1]), pid: Number.isInteger(pid) ? pid : null, name });
|
|
315
|
+
}
|
|
316
|
+
return rows;
|
|
317
|
+
}
|
|
318
|
+
const res = spawnSync('ss', ['-ltnp'], { encoding: 'utf8', timeout: OS_SCAN_TIMEOUT_MS });
|
|
319
|
+
if (!res.stdout) return [];
|
|
320
|
+
for (const line of res.stdout.split('\n')) {
|
|
321
|
+
const portMatch = line.match(/:(\d+)\s/);
|
|
322
|
+
const nameMatch = line.match(/users:\(\("([^"]+)"/);
|
|
323
|
+
if (!portMatch || !nameMatch || !isNodeLikeListenerName(nameMatch[1])) continue;
|
|
324
|
+
const pidMatch = line.match(/pid=(\d+)/);
|
|
325
|
+
rows.push({ port: Number(portMatch[1]), pid: pidMatch ? Number(pidMatch[1]) : null, name: nameMatch[1] });
|
|
326
|
+
}
|
|
327
|
+
return rows;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function scanWideListeners() {
|
|
331
|
+
try {
|
|
332
|
+
return process.platform === 'win32' ? scanWideListenersWindows() : scanWideListenersUnix();
|
|
333
|
+
} catch {
|
|
334
|
+
// Best-effort discovery only — the base candidatePorts scan still covers
|
|
335
|
+
// the common case without this.
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- HTTP liveness + identity probe (authoritative "is it up?") ------------
|
|
341
|
+
|
|
342
|
+
function probePort(port) {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const req = http.request(
|
|
345
|
+
// Probe `localhost` (not a hardcoded 127.0.0.1) so resolution matches how
|
|
346
|
+
// Next bound — on Windows `localhost` often resolves to ::1 (IPv6), and a
|
|
347
|
+
// hardcoded IPv4 connect would miss a server listening only on ::1.
|
|
348
|
+
{ host: 'localhost', port, path: PROBE_PATH, method: 'GET', timeout: HTTP_PROBE_TIMEOUT_MS },
|
|
349
|
+
(res) => {
|
|
350
|
+
const poweredBy = String(res.headers['x-powered-by'] || '');
|
|
351
|
+
const chunks = [];
|
|
352
|
+
let bytes = 0;
|
|
353
|
+
res.on('data', (chunk) => {
|
|
354
|
+
if (bytes < 512) chunks.push(chunk);
|
|
355
|
+
bytes += chunk.length;
|
|
356
|
+
});
|
|
357
|
+
res.on('end', () => {
|
|
358
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
359
|
+
const isConsole =
|
|
360
|
+
/next/i.test(poweredBy) ||
|
|
361
|
+
(res.statusCode === 200 &&
|
|
362
|
+
String(res.headers['content-type'] || '').includes('application/json') &&
|
|
363
|
+
(() => {
|
|
364
|
+
try {
|
|
365
|
+
return typeof JSON.parse(body).buildId === 'string';
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
})());
|
|
370
|
+
resolve({
|
|
371
|
+
port,
|
|
372
|
+
responded: true,
|
|
373
|
+
httpStatus: res.statusCode ?? null,
|
|
374
|
+
probe: isConsole ? 'console' : 'unknown',
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
req.on('timeout', () => {
|
|
380
|
+
req.destroy();
|
|
381
|
+
resolve({ port, responded: false, httpStatus: null, probe: 'no-response' });
|
|
382
|
+
});
|
|
383
|
+
req.on('error', () => {
|
|
384
|
+
resolve({ port, responded: false, httpStatus: null, probe: 'no-response' });
|
|
385
|
+
});
|
|
386
|
+
req.end();
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// HS-09JU: retry probePort with backoff before concluding a port is dead.
|
|
391
|
+
// A single 4s attempt can land squarely inside a cold Next dev compile and
|
|
392
|
+
// miss it; a retry after PROBE_RETRY_DELAY_MS gives the compile a second
|
|
393
|
+
// window. Returns as soon as any attempt responds — this only adds latency to
|
|
394
|
+
// the "actually not there" case, never to the "it's live" case.
|
|
395
|
+
async function probePortWithRetry(port) {
|
|
396
|
+
let last = { port, responded: false, httpStatus: null, probe: 'no-response' };
|
|
397
|
+
for (let attempt = 0; attempt < PROBE_RETRY_ATTEMPTS; attempt++) {
|
|
398
|
+
last = await probePort(port);
|
|
399
|
+
if (last.responded) return last;
|
|
400
|
+
if (attempt < PROBE_RETRY_ATTEMPTS - 1) {
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, PROBE_RETRY_DELAY_MS));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return last;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// HS-D4O9-DIAG: the PAGE-render probe — a second, independent liveness check
|
|
408
|
+
// against a real page ROUTE (not an API handler). A 5xx response or a timeout
|
|
409
|
+
// means the root layout/page cannot compile; 2xx/3xx/4xx all mean the
|
|
410
|
+
// framework itself is up and routing (a 404 is a routing miss, not a compile
|
|
411
|
+
// failure; a 3xx is a legitimate auth-middleware redirect, e.g. Clerk sending
|
|
412
|
+
// an unauthenticated request to /sign-in — neither should be misread as
|
|
413
|
+
// "broken"). Mirrors probePort's { responded, httpStatus } shape but adds
|
|
414
|
+
// `ok` (the pass/fail verdict this probe exists to produce) instead of a
|
|
415
|
+
// console/unknown classification, which only the API probe needs.
|
|
416
|
+
function probePage(port, pagePath = PAGE_PROBE_PATH) {
|
|
417
|
+
return new Promise((resolve) => {
|
|
418
|
+
const req = http.request(
|
|
419
|
+
{ host: 'localhost', port, path: pagePath, method: 'GET', timeout: PAGE_PROBE_TIMEOUT_MS },
|
|
420
|
+
(res) => {
|
|
421
|
+
res.resume(); // drain the body — only the status code matters here
|
|
422
|
+
res.on('end', () => {
|
|
423
|
+
const httpStatus = res.statusCode ?? null;
|
|
424
|
+
resolve({
|
|
425
|
+
path: pagePath,
|
|
426
|
+
responded: true,
|
|
427
|
+
httpStatus,
|
|
428
|
+
timedOut: false,
|
|
429
|
+
ok: typeof httpStatus === 'number' && httpStatus < 500,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
req.on('timeout', () => {
|
|
435
|
+
req.destroy();
|
|
436
|
+
resolve({ path: pagePath, responded: false, httpStatus: null, timedOut: true, ok: false });
|
|
437
|
+
});
|
|
438
|
+
req.on('error', () => {
|
|
439
|
+
resolve({ path: pagePath, responded: false, httpStatus: null, timedOut: false, ok: false });
|
|
440
|
+
});
|
|
441
|
+
req.end();
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---- Discrepancy guidance (HS-09JU) -----------------------------------------
|
|
446
|
+
//
|
|
447
|
+
// Every discrepancy flag below fires ONLY in the presence of some server
|
|
448
|
+
// evidence (a live console server the lockfile doesn't vouch for, a lock
|
|
449
|
+
// pointing at the wrong port, a page-render failure on an otherwise-live
|
|
450
|
+
// server, …) — none of them mean "nothing is running, go ahead and start
|
|
451
|
+
// one". Pre-fix, callers (the SessionStart awareness hook, agents reading
|
|
452
|
+
// this tool's output) read "trust the verdict and any discrepancy flags" as
|
|
453
|
+
// license to treat a flag as a restart TRIGGER; the flags are diagnostic
|
|
454
|
+
// information about WHY the lockfile disagreed with reality, not a verdict
|
|
455
|
+
// that no server exists. This table is additive — `discrepancies` (the plain
|
|
456
|
+
// string codes) is unchanged for existing consumers; `discrepancyGuidance`
|
|
457
|
+
// is the new field that spells out the correct reading for each code.
|
|
458
|
+
const DISCREPANCY_GUIDANCE_TEXT = {
|
|
459
|
+
stale_lock:
|
|
460
|
+
'The lockfile looks stale (its recorded launcher pid is dead, or it claims a server that is not answering) — this is NOT proof no server is running. A launcher can die on Windows while the `next dev` it started keeps running. Check `running`/`servers` in THIS SAME response first: if a console server is listed, do NOT start a new one — reuse it. Only treat the server as actually down when `running` is false and no server entry answers.',
|
|
461
|
+
untracked_server:
|
|
462
|
+
'A live spex-console server is answering that no lockfile/console-url vouches for (started outside the wrapper with a bare `next dev`, or the discovery file was cleaned). This means a server IS running right now — do NOT start a second one; two servers compiling into the same .next corrupts it. Reuse the server shown in `servers`; only stop-then-relaunch via `npm run dev` if you specifically need it wrapper-managed.',
|
|
463
|
+
port_mismatch:
|
|
464
|
+
'The lockfile points at one port but the live console server is answering on a different one. A server IS running (see `servers`) — do not start a new one; use the port shown in `servers`, not the lockfile\'s port.',
|
|
465
|
+
multiple_servers:
|
|
466
|
+
'More than one console server is answering at once — this is the corrupted-.next state two concurrent `next dev` processes cause. Do NOT start another. Stop the extras first (`hs server stop` / `npm run console:stop`).',
|
|
467
|
+
page_probe_failed:
|
|
468
|
+
'The API probe answered but a real page route failed to render (HS-D4O9-DIAG: e.g. a missing dependency leaves every page throwing while API handlers keep answering 200). The server is up but likely broken — check server logs / reinstall deps. Restarting the dev server will usually NOT fix this on its own, so do not treat this flag as "not running".',
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Expand `discrepancies` (plain string codes, unchanged for backward
|
|
473
|
+
* compatibility) into the guidance objects new callers should read instead of
|
|
474
|
+
* inferring meaning from the bare code. Every known code is restart-blocking
|
|
475
|
+
* by construction (see the table's own comment above) — an unrecognized
|
|
476
|
+
* future code still gets a safe generic message rather than being silently
|
|
477
|
+
* dropped.
|
|
478
|
+
*/
|
|
479
|
+
function buildDiscrepancyGuidance(discrepancies) {
|
|
480
|
+
return discrepancies.map((code) => ({
|
|
481
|
+
code,
|
|
482
|
+
restartBlocking: true,
|
|
483
|
+
message:
|
|
484
|
+
DISCREPANCY_GUIDANCE_TEXT[code] ||
|
|
485
|
+
'Diagnostic flag — verify `running`/`servers` above before treating this as license to start a server.',
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Ground-truth dev-server status. Returns a structured verdict whose `running`
|
|
491
|
+
* field is derived from the HTTP probe (never from the lockfile alone), plus
|
|
492
|
+
* the discrepancy flags that name every way the lockfile disagreed with reality.
|
|
493
|
+
*/
|
|
494
|
+
export async function getDevServerStatus({ vaultRoot, ports, pagePath } = {}) {
|
|
495
|
+
const root = vaultRoot || defaultVaultRoot();
|
|
496
|
+
const basePorts = candidatePorts(root, ports);
|
|
497
|
+
|
|
498
|
+
// HS-09JU: fold in any node-like listener the wide OS scan finds outside
|
|
499
|
+
// `basePorts` (DEFAULT_PORTS + lockfile/console-url hints) — a live console
|
|
500
|
+
// server that Next auto-bumped past 3005, or that was started with a manual
|
|
501
|
+
// `-p`, must still be found and probed, not reported dead just because it's
|
|
502
|
+
// off the well-known range. Skipped when the caller passed an explicit
|
|
503
|
+
// `ports` override (tests, a narrowed `hs server status` check) — that's
|
|
504
|
+
// "check exactly these ports", so widening would defeat the point.
|
|
505
|
+
const explicitPorts = Array.isArray(ports) && ports.length > 0;
|
|
506
|
+
const wideRows = explicitPorts ? [] : scanWideListeners();
|
|
507
|
+
const discoveredPorts = wideRows.map((r) => r.port).filter((p) => !basePorts.includes(p));
|
|
508
|
+
const portsToCheck = [...new Set([...basePorts, ...discoveredPorts])];
|
|
509
|
+
|
|
510
|
+
const [probes, baseOsRows] = await Promise.all([
|
|
511
|
+
Promise.all(portsToCheck.map((p) => probePortWithRetry(p))),
|
|
512
|
+
Promise.resolve(scanListeners(basePorts)),
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
const osByPort = new Map();
|
|
516
|
+
for (const row of baseOsRows) {
|
|
517
|
+
if (!osByPort.has(row.port)) osByPort.set(row.port, row);
|
|
518
|
+
}
|
|
519
|
+
// Ports the wide scan discovered aren't in `basePorts`, so the exact-match
|
|
520
|
+
// scan above never attributed them — attribute them from the wide scan's
|
|
521
|
+
// own pid/name data instead (it already has it; no third OS call needed).
|
|
522
|
+
for (const row of wideRows) {
|
|
523
|
+
if (!osByPort.has(row.port)) osByPort.set(row.port, row);
|
|
524
|
+
}
|
|
525
|
+
const osRows = [...osByPort.values()];
|
|
526
|
+
|
|
527
|
+
// A server entry exists for any port that EITHER answered HTTP OR the OS
|
|
528
|
+
// reports as listening — so an OS-listening-but-HTTP-dead port is still shown.
|
|
529
|
+
const ports_seen = new Set([
|
|
530
|
+
...probes.filter((p) => p.responded).map((p) => p.port),
|
|
531
|
+
...osRows.map((r) => r.port),
|
|
532
|
+
]);
|
|
533
|
+
|
|
534
|
+
// HS-D4O9-DIAG: the page probe only makes sense against a port the API probe
|
|
535
|
+
// already identified as spex-console — probing an 'unknown'/'no-response' port
|
|
536
|
+
// for a page render would just add noise, and a port with no server at all has
|
|
537
|
+
// nothing to probe. Run these in parallel with everything above already
|
|
538
|
+
// resolved so we know exactly which ports qualify.
|
|
539
|
+
const consolePorts = [...ports_seen].filter(
|
|
540
|
+
(port) => (probes.find((p) => p.port === port) || {}).probe === 'console',
|
|
541
|
+
);
|
|
542
|
+
const pageProbes = await Promise.all(consolePorts.map((port) => probePage(port, pagePath)));
|
|
543
|
+
const pageProbeByPort = new Map(consolePorts.map((port, i) => [port, pageProbes[i]]));
|
|
544
|
+
|
|
545
|
+
const servers = [...ports_seen]
|
|
546
|
+
.sort((a, b) => a - b)
|
|
547
|
+
.map((port) => {
|
|
548
|
+
const probe = probes.find((p) => p.port === port) || { responded: false, httpStatus: null, probe: 'no-response' };
|
|
549
|
+
const os = osByPort.get(port) || { pid: null, name: '' };
|
|
550
|
+
return {
|
|
551
|
+
port,
|
|
552
|
+
pid: os.pid,
|
|
553
|
+
name: os.name,
|
|
554
|
+
url: `http://localhost:${port}`,
|
|
555
|
+
listening: true,
|
|
556
|
+
responded: probe.responded,
|
|
557
|
+
httpStatus: probe.httpStatus,
|
|
558
|
+
probe: probe.probe, // 'console' | 'unknown' | 'no-response'
|
|
559
|
+
// Additive (HS-D4O9-DIAG): null for any port the API probe didn't call
|
|
560
|
+
// 'console' — the page probe never runs against those.
|
|
561
|
+
pageProbe: pageProbeByPort.get(port) ?? null,
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const consoleServers = servers.filter((s) => s.probe === 'console');
|
|
566
|
+
const running = consoleServers.length > 0;
|
|
567
|
+
// Additive top-level summary (HS-D4O9-DIAG): true only when every identified
|
|
568
|
+
// console server's page probe also succeeded; null when there is no console
|
|
569
|
+
// server to judge (distinct from false — "not applicable" vs "checked and
|
|
570
|
+
// failed"). Deliberately NOT folded into `running` — that field's existing
|
|
571
|
+
// API-probe-only meaning stays backward compatible for callers already keyed
|
|
572
|
+
// off it; this is the new, stricter "is the UI actually usable" signal.
|
|
573
|
+
const uiHealthy = consoleServers.length === 0 ? null : consoleServers.every((s) => s.pageProbe?.ok === true);
|
|
574
|
+
|
|
575
|
+
const lock = readLockfile(root);
|
|
576
|
+
const lockfile = {
|
|
577
|
+
present: lock.present,
|
|
578
|
+
pid: lock.pid,
|
|
579
|
+
url: lock.url,
|
|
580
|
+
startedAt: lock.startedAt,
|
|
581
|
+
// Bind host as the launcher recorded it. null ⇒ UNKNOWN (no lock, or a lock
|
|
582
|
+
// written before the loopback-default change) — never read null as loopback.
|
|
583
|
+
host: lock.host,
|
|
584
|
+
lan: lock.lan,
|
|
585
|
+
pidAlive: lock.present ? pidAlive(lock.pid) : null,
|
|
586
|
+
};
|
|
587
|
+
const consoleUrlFile = readConsoleUrlFile(root);
|
|
588
|
+
|
|
589
|
+
// ---- Reconcile: surface every disagreement ----
|
|
590
|
+
//
|
|
591
|
+
// Reconcile by PORT, not pid. The lock records the LAUNCHER pid
|
|
592
|
+
// (ensure-next-dev.mjs), while the OS reports the listening pid (its child
|
|
593
|
+
// `next dev`) — they never match, so a pid comparison would false-flag every
|
|
594
|
+
// properly-wrapped server as untracked. The port the lock points at is the
|
|
595
|
+
// stable join key.
|
|
596
|
+
const discrepancies = [];
|
|
597
|
+
const lockPort = portFromUrl(lock.url);
|
|
598
|
+
// Ports the lockfile / console-url file vouch for (a console server here is
|
|
599
|
+
// "tracked"). console-url alone is a weaker signal (it isn't cleared as
|
|
600
|
+
// reliably as the lock), so it only widens the tracked set, never narrows it.
|
|
601
|
+
const trackedPorts = new Set([lockPort, portFromUrl(consoleUrlFile)].filter((p) => p != null));
|
|
602
|
+
|
|
603
|
+
if (lock.present) {
|
|
604
|
+
if (!lockfile.pidAlive) {
|
|
605
|
+
// Launcher process is gone but the lock file survives → stale.
|
|
606
|
+
discrepancies.push('stale_lock');
|
|
607
|
+
} else if (consoleServers.length === 0) {
|
|
608
|
+
// Lock claims a live server (launcher alive) but nothing console-like is
|
|
609
|
+
// actually answering → the lock is lying about "running".
|
|
610
|
+
discrepancies.push('stale_lock');
|
|
611
|
+
}
|
|
612
|
+
if (lockPort != null && consoleServers.length > 0 && !consoleServers.some((s) => s.port === lockPort)) {
|
|
613
|
+
// Lock points at one port; the live console server is on another.
|
|
614
|
+
discrepancies.push('port_mismatch');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (consoleServers.some((s) => !trackedPorts.has(s.port))) {
|
|
619
|
+
// A live console server on a port no lock/console-url vouches for — a bare
|
|
620
|
+
// `next dev` started outside the wrapper, with no lock to record it.
|
|
621
|
+
discrepancies.push('untracked_server');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (consoleServers.length > 1) discrepancies.push('multiple_servers');
|
|
625
|
+
|
|
626
|
+
// HS-D4O9-DIAG: the API probe answering is NOT proof the console is usable —
|
|
627
|
+
// surface the page-compile failure as its own discrepancy so a caller that
|
|
628
|
+
// already inspects `discrepancies` (the existing extension point for "the
|
|
629
|
+
// lockfile/API signal disagrees with reality") picks this up for free,
|
|
630
|
+
// without needing to know about the new `uiHealthy`/`pageProbe` fields.
|
|
631
|
+
if (consoleServers.some((s) => s.pageProbe && s.pageProbe.ok === false)) {
|
|
632
|
+
discrepancies.push('page_probe_failed');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
running,
|
|
637
|
+
// Additive (HS-D4O9-DIAG): the stricter "does the UI actually render"
|
|
638
|
+
// signal — see the field's own comment above for why it's separate from
|
|
639
|
+
// `running` rather than folded into it. null = no console server to judge.
|
|
640
|
+
uiHealthy,
|
|
641
|
+
servers,
|
|
642
|
+
lockfile,
|
|
643
|
+
consoleUrlFile,
|
|
644
|
+
candidatePorts: portsToCheck,
|
|
645
|
+
discrepancies: [...new Set(discrepancies)],
|
|
646
|
+
// Additive (HS-09JU): the corrected reading of `discrepancies` — every
|
|
647
|
+
// entry is restart-BLOCKING (evidence a server exists), never a restart
|
|
648
|
+
// trigger. See DISCREPANCY_GUIDANCE_TEXT above for the per-code rationale.
|
|
649
|
+
discrepancyGuidance: buildDiscrepancyGuidance([...new Set(discrepancies)]),
|
|
650
|
+
platform: process.platform,
|
|
651
|
+
checkedAt: new Date().toISOString(),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---- Human-readable one-liner (for the CLI + hook reminders) ---------------
|
|
656
|
+
|
|
657
|
+
export function summarizeStatus(status) {
|
|
658
|
+
if (!status.running && status.servers.length === 0) {
|
|
659
|
+
return 'Dev server: NOT running (no listener on candidate ports).';
|
|
660
|
+
}
|
|
661
|
+
const parts = status.servers.map((s) => {
|
|
662
|
+
const who = s.pid ? ` pid ${s.pid}${s.name ? ` (${s.name})` : ''}` : '';
|
|
663
|
+
const tag =
|
|
664
|
+
s.probe === 'console' ? 'spex-console' : s.probe === 'unknown' ? 'non-console process' : 'listening, no HTTP response';
|
|
665
|
+
// HS-D4O9-DIAG: name the page-probe failure right on the server line — the
|
|
666
|
+
// discrepancy list below already carries `page_probe_failed`, but a human
|
|
667
|
+
// scanning per-server lines should see it without cross-referencing.
|
|
668
|
+
const pageNote =
|
|
669
|
+
s.pageProbe && s.pageProbe.ok === false
|
|
670
|
+
? ` — ⚠ PAGE PROBE FAILED (${s.pageProbe.timedOut ? 'timeout' : `HTTP ${s.pageProbe.httpStatus ?? 'no response'}`} on ${s.pageProbe.path})`
|
|
671
|
+
: '';
|
|
672
|
+
return `:${s.port} — ${tag}${who}${pageNote}`;
|
|
673
|
+
});
|
|
674
|
+
// running=true (API answered) but uiHealthy===false (page compile failed) is
|
|
675
|
+
// exactly the HS-D4O9-DIAG false-positive this fix closes — the headline must
|
|
676
|
+
// say so instead of a flat "RUNNING" that reads as "everything is fine".
|
|
677
|
+
const head = !status.running
|
|
678
|
+
? 'Dev server: port(s) occupied (no console identified)'
|
|
679
|
+
: status.uiHealthy === false
|
|
680
|
+
? 'Dev server: RUNNING but UI NOT rendering (API up, page compile FAILED)'
|
|
681
|
+
: 'Dev server: RUNNING';
|
|
682
|
+
// HS-09JU: discrepancies are diagnostic, not restart triggers — a flag next
|
|
683
|
+
// to a RUNNING headline still means "a server exists"; spell that out here
|
|
684
|
+
// so a human (or an agent) reading only this line, not the JSON, doesn't
|
|
685
|
+
// misread the flag list as "start a new one".
|
|
686
|
+
const flags = status.discrepancies.length
|
|
687
|
+
? `\n⚠ discrepancies (diagnostic — do NOT restart because of these; see \`running\`/\`servers\` above): ${status.discrepancies.join(', ')}`
|
|
688
|
+
: '';
|
|
689
|
+
// Bind host: only worth a line when the launcher recorded one. LAN mode is
|
|
690
|
+
// called out because it is the state that exposes the console off-machine.
|
|
691
|
+
const lock = status.lockfile;
|
|
692
|
+
const bind = !lock?.present
|
|
693
|
+
? ''
|
|
694
|
+
: lock.lan === true
|
|
695
|
+
? `\n bind: ${lock.host ?? '?'} — ⚠ LAN MODE (reachable from your network; only /api/env-vault/join needs this)`
|
|
696
|
+
: lock.lan === false
|
|
697
|
+
? `\n bind: ${lock.host ?? '?'} — loopback only`
|
|
698
|
+
: '';
|
|
699
|
+
return `${head}\n ${parts.join('\n ')}${bind}${flags}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---- CLI entrypoint --------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
705
|
+
if (invokedDirectly) {
|
|
706
|
+
const asJson = process.argv.includes('--json');
|
|
707
|
+
getDevServerStatus()
|
|
708
|
+
.then((status) => {
|
|
709
|
+
if (asJson) {
|
|
710
|
+
process.stdout.write(JSON.stringify(status, null, 2) + '\n');
|
|
711
|
+
} else {
|
|
712
|
+
process.stdout.write(summarizeStatus(status) + '\n');
|
|
713
|
+
}
|
|
714
|
+
process.exit(0);
|
|
715
|
+
})
|
|
716
|
+
.catch((err) => {
|
|
717
|
+
// Even on failure, print a usable message and exit 0 — callers (hook, CLI)
|
|
718
|
+
// should never be broken by a status check.
|
|
719
|
+
process.stdout.write(`Dev server status check failed: ${err?.message ?? err}\n`);
|
|
720
|
+
process.exit(0);
|
|
721
|
+
});
|
|
722
|
+
}
|