@catheadowl/ukp 0.2.5 → 0.2.6

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.
@@ -0,0 +1,480 @@
1
+ /** Windows resident-door health model (O-020 ruling B2): the read-only
2
+ * checks behind `ukp diagnose --door`. A "door" here is what
3
+ * `ukp serve --print-task` sets up: a scheduled task whose action runs
4
+ * wscript.exe on a hidden launcher (start-*-hidden.vbs) under
5
+ * %USERPROFILE%\.ukp, which runs the start script (start-*.cmd, carrying
6
+ * the serve line and the token) and owns door.log. Multi-door hosts rename
7
+ * the trio per door (field shape: ukp-door / ukp-door-<name>), so
8
+ * discovery matches the task ACTION (wscript + a .vbs under .ukp), never a
9
+ * fixed task name.
10
+ *
11
+ * Read-only by construction: the system facts arrive as one PowerShell
12
+ * snapshot (task roster, process table, LISTEN sockets) - no process is
13
+ * killed, no file written, no task registered or unregistered, and the
14
+ * "port bind dry-run" is a listener-table lookup, not a real bind (a real
15
+ * listen could pop a firewall prompt and race the launcher's crash retry).
16
+ * The start script is read but only the serve line's --endpoint/--select/
17
+ * --host/--port values are ever echoed; the UKP_SERVE_TOKEN line stays in the
18
+ * file (secret-free output, D-090 convention). Repair knowledge is
19
+ * pointers only: `ukp serve --print-task` section 5 and the deployment
20
+ * handbook - diagnose says what is wrong, never fixes it. */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { readFileSync } from "node:fs";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // System snapshot (injectable in full for tests)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface DoorTaskRow {
30
+ name: string;
31
+ state: string;
32
+ execute: string;
33
+ arguments: string;
34
+ }
35
+
36
+ export interface DoorProcessRow {
37
+ pid: number;
38
+ ppid: number;
39
+ name: string;
40
+ commandLine: string;
41
+ }
42
+
43
+ export interface DoorListenerRow {
44
+ address: string;
45
+ port: number;
46
+ pid: number;
47
+ }
48
+
49
+ export interface DoorSystemSnapshot {
50
+ tasks: DoorTaskRow[];
51
+ processes: DoorProcessRow[];
52
+ listeners: DoorListenerRow[];
53
+ }
54
+
55
+ export type DoorSystemProbe = () => DoorSystemSnapshot;
56
+
57
+ /** Default file reader for the operator-authored door files. */
58
+ export function defaultReadDoorFile(path: string): string {
59
+ return readFileSync(path, "utf8");
60
+ }
61
+
62
+ /** The one-shot PowerShell collector. Everything diagnose needs about
63
+ * tasks, processes, and listeners rides in a single spawn (each direct
64
+ * bun->tool edge is Defender-taxed on win32; powershell as the carrier is
65
+ * the measured-fast shape, see spawn-relay.ts) and comes back as JSON.
66
+ * `[Console]::OutputEncoding` is set so non-ASCII task names survive the
67
+ * pipe as UTF-8. */
68
+ export function defaultDoorSystemProbe(): DoorSystemSnapshot {
69
+ const script = [
70
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
71
+ "$tasks = @(Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object {",
72
+ " $a = @($_.Actions)[0]",
73
+ ' [pscustomobject]@{ name = $_.TaskName; state = [string]$_.State; execute = [string]$a.Execute; arguments = [string]$a.Arguments }',
74
+ "})",
75
+ "$processes = @(Get-CimInstance Win32_Process | ForEach-Object {",
76
+ ' [pscustomobject]@{ pid = [int]$_.ProcessId; ppid = [int]$_.ParentProcessId; name = [string]$_.Name; commandLine = [string]$_.CommandLine }',
77
+ "})",
78
+ "$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ForEach-Object {",
79
+ ' [pscustomobject]@{ address = [string]$_.LocalAddress; port = [int]$_.LocalPort; pid = [int]$_.OwningProcess }',
80
+ "})",
81
+ '[pscustomobject]@{ tasks = $tasks; processes = $processes; listeners = $listeners } | ConvertTo-Json -Depth 4 -Compress',
82
+ ].join("\n");
83
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
84
+ const result = spawnSync("powershell", ["-NoProfile", "-EncodedCommand", encoded], {
85
+ encoding: "utf8",
86
+ windowsHide: true,
87
+ timeout: 20_000,
88
+ });
89
+ if (result.error) {
90
+ throw new Error(`could not run powershell for the door snapshot: ${result.error.message}`);
91
+ }
92
+ if (result.status !== 0) {
93
+ throw new Error(`the door snapshot query failed (powershell exit ${result.status})`);
94
+ }
95
+ const raw = (result.stdout ?? "").replace(/^\uFEFF/, "").trim();
96
+ if (raw.length === 0) {
97
+ throw new Error("the door snapshot query returned nothing");
98
+ }
99
+ const parsed = JSON.parse(raw) as Partial<DoorSystemSnapshot>;
100
+ return {
101
+ tasks: (parsed.tasks ?? []).map((row) => ({ ...row, arguments: row.arguments ?? "" })),
102
+ processes: (parsed.processes ?? []).map((row) => ({
103
+ pid: Number(row.pid),
104
+ ppid: Number(row.ppid),
105
+ name: row.name ?? "",
106
+ commandLine: row.commandLine ?? "",
107
+ })),
108
+ listeners: (parsed.listeners ?? []).map((row) => ({
109
+ address: row.address ?? "",
110
+ port: Number(row.port),
111
+ pid: Number(row.pid),
112
+ })),
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Door discovery and per-door evaluation
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export interface DoorReport {
121
+ taskName: string;
122
+ taskState: string;
123
+ /** Resolved launcher (.vbs) path; absent when the action path could not
124
+ * be resolved (task is then structurally failed). */
125
+ launcherPath?: string;
126
+ scriptPath?: string;
127
+ serve?: { endpoint?: string; select?: string[]; host?: string; port?: number };
128
+ /** Main root-to-leaf chain (wscript -> cmd -> ... -> bun); absent when no
129
+ * launcher process was found. */
130
+ tree?: Array<{ pid: number; name: string }>;
131
+ port?: {
132
+ port: number;
133
+ listening: boolean;
134
+ holderPid?: number;
135
+ holderName?: string;
136
+ inTree?: boolean;
137
+ };
138
+ log?: {
139
+ path: string;
140
+ banner: boolean;
141
+ lastListening?: string;
142
+ eaddrinuse: boolean;
143
+ sharingViolation: boolean;
144
+ };
145
+ warnings: string[];
146
+ status: "ok" | "warning" | "failed";
147
+ }
148
+
149
+ export interface DoorEvaluationEnv {
150
+ homeDir: string;
151
+ readTextFile: (path: string) => string;
152
+ }
153
+
154
+ function normalizeKeyPath(path: string): string {
155
+ return path.replaceAll("/", "\\").toLowerCase();
156
+ }
157
+
158
+ function stripSurroundingQuotes(value: string): string {
159
+ return value.trim().replace(/^"+/, "").replace(/"+$/, "");
160
+ }
161
+
162
+ /** A door task = an action running wscript.exe on a .vbs under a `.ukp`
163
+ * directory (the print-task launcher shape, rename-tolerant: any task/vbs
164
+ * name qualifies as long as the shape holds). */
165
+ export function isDoorTaskAction(execute: string, args: string): boolean {
166
+ if (!/wscript\.exe$/i.test(execute.trim())) return false;
167
+ const clean = stripSurroundingQuotes(args).replaceAll("/", "\\");
168
+ return /\\.ukp\\.*\.vbs$/i.test(clean);
169
+ }
170
+
171
+ /** Task action argument -> launcher path. Only %USERPROFILE% (the
172
+ * template's variable) is expanded; any other %VAR% marks the action
173
+ * unresolvable from userland. */
174
+ export function resolveDoorLauncherPath(
175
+ args: string,
176
+ homeDir: string,
177
+ ): { path: string; resolved: boolean } {
178
+ const clean = stripSurroundingQuotes(args);
179
+ const expanded = clean.replace(/%USERPROFILE%/gi, homeDir);
180
+ return { path: expanded, resolved: !expanded.includes("%") };
181
+ }
182
+
183
+ /** The .vbs path from a live wscript command line (last quoted .vbs token). */
184
+ function vbsPathFromCommandLine(commandLine: string): string | undefined {
185
+ const matches = [...commandLine.matchAll(/"([^"]+\.vbs)"/gi)].map((m) => m[1]!);
186
+ return matches.at(-1);
187
+ }
188
+
189
+ /** The serve line's flags from the start script text; undefined pieces are
190
+ * simply absent. Only these values are ever echoed - the token line in the
191
+ * same file is never reproduced. `--select` (ADR-REM-010 subset doors)
192
+ * parses to the name list. */
193
+ export function parseServeLine(scriptText: string): { endpoint?: string; select?: string[]; host?: string; port?: number } | undefined {
194
+ const line = scriptText
195
+ .split(/\r?\n/)
196
+ .map((entry) => entry.trim())
197
+ .find((entry) => /^ukp\s+serve(\s|$)/.test(entry));
198
+ if (line === undefined) return undefined;
199
+ const endpoint = /--endpoint\s+(\S+)/.exec(line)?.[1];
200
+ const selectRaw = /--select\s+(\S+)/.exec(line)?.[1];
201
+ const host = /--host\s+(\S+)/.exec(line)?.[1];
202
+ const portRaw = /--port\s+(\d+)/.exec(line)?.[1];
203
+ return {
204
+ ...(endpoint !== undefined ? { endpoint } : {}),
205
+ ...(selectRaw !== undefined ? { select: selectRaw.split(",") } : {}),
206
+ ...(host !== undefined ? { host } : {}),
207
+ ...(portRaw !== undefined ? { port: Number(portRaw) } : {}),
208
+ };
209
+ }
210
+
211
+ /** door.log path: the live cmd process carries the full redirect target;
212
+ * statically, the vbs names the log file fragment under .ukp. */
213
+ function findLogPath(liveCmdCommandLine: string | undefined, vbsText: string | undefined, launcherPath: string): string | undefined {
214
+ const live = liveCmdCommandLine !== undefined ? />>\s*"([^"]+)"/.exec(liveCmdCommandLine)?.[1] : undefined;
215
+ if (live !== undefined) return live;
216
+ const fragment = vbsText !== undefined ? /\\\.ukp\\([^\r\n"']+?\.log)/i.exec(vbsText)?.[1] : undefined;
217
+ if (fragment !== undefined) {
218
+ const dir = launcherPath.slice(0, Math.max(launcherPath.lastIndexOf("\\"), launcherPath.lastIndexOf("/")));
219
+ return `${dir}\\${fragment.replaceAll("/", "\\")}`;
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ function evaluateDoor(report: DoorReport, snapshot: DoorSystemSnapshot, env: DoorEvaluationEnv): DoorReport {
225
+ const warnings = report.warnings;
226
+ const failed = report.status === "failed";
227
+
228
+ // Launcher script readability (operator-authored; tolerance, not failure).
229
+ let vbsText: string | undefined;
230
+ if (report.launcherPath !== undefined) {
231
+ try {
232
+ vbsText = env.readTextFile(report.launcherPath);
233
+ } catch {
234
+ warnings.push(
235
+ `launcher script not found at ${report.launcherPath} - the task references a deleted file; remove the task ('ukp serve --print-task' section 5, level 2) or restore the file`,
236
+ );
237
+ }
238
+ }
239
+
240
+ // Start script (the -hidden.vbs -> .cmd naming convention) and its serve line.
241
+ let scriptText: string | undefined;
242
+ if (report.scriptPath !== undefined) {
243
+ try {
244
+ scriptText = env.readTextFile(report.scriptPath);
245
+ } catch {
246
+ warnings.push(
247
+ `start script not found at ${report.scriptPath} - the door's port cannot be determined; restore it or remove the door ('ukp serve --print-task' section 5)`,
248
+ );
249
+ }
250
+ }
251
+ if (scriptText !== undefined) {
252
+ const serve = parseServeLine(scriptText);
253
+ if (serve === undefined) {
254
+ warnings.push(
255
+ `no 'ukp serve' line found in ${report.scriptPath} - the door's port cannot be determined from the script`,
256
+ );
257
+ } else {
258
+ report.serve = serve;
259
+ if (serve.port === undefined) {
260
+ warnings.push(`the serve line in ${report.scriptPath} carries no --port - the door's port cannot be determined`);
261
+ }
262
+ }
263
+ }
264
+
265
+ // Process tree: the wscript whose command line names this door's vbs.
266
+ const launcherKey = report.launcherPath !== undefined ? normalizeKeyPath(report.launcherPath) : undefined;
267
+ const root = snapshot.processes.find((proc) => {
268
+ if (!/^wscript\.exe$/i.test(proc.name)) return false;
269
+ const vbs = vbsPathFromCommandLine(proc.commandLine);
270
+ return vbs !== undefined && launcherKey !== undefined && normalizeKeyPath(vbs) === launcherKey;
271
+ });
272
+ let treePidSet: Set<number> | undefined;
273
+ if (root !== undefined) {
274
+ const children = new Map<number, DoorProcessRow[]>();
275
+ for (const proc of snapshot.processes) {
276
+ const list = children.get(proc.ppid) ?? [];
277
+ list.push(proc);
278
+ children.set(proc.ppid, list);
279
+ }
280
+ const treePids = new Set<number>([root.pid]);
281
+ const chain: Array<{ pid: number; name: string }> = [{ pid: root.pid, name: root.name.replace(/\.exe$/i, "") }];
282
+ let frontier: DoorProcessRow[] = [root];
283
+ while (frontier.length > 0) {
284
+ // Main chain = first child per level; siblings only join the pid set.
285
+ const next: DoorProcessRow[] = [];
286
+ for (const parent of frontier) {
287
+ for (const child of children.get(parent.pid) ?? []) {
288
+ treePids.add(child.pid);
289
+ next.push(child);
290
+ }
291
+ }
292
+ if (next.length > 0) {
293
+ chain.push({ pid: next[0]!.pid, name: next[0]!.name.replace(/\.exe$/i, "") });
294
+ }
295
+ frontier = next;
296
+ }
297
+ treePidSet = treePids;
298
+ report.tree = chain;
299
+ }
300
+
301
+ // Port cross-check: the listener table decides, never a real bind.
302
+ const port = report.serve?.port;
303
+ if (port !== undefined) {
304
+ const holder = snapshot.listeners.find((entry) => entry.port === port);
305
+ if (holder === undefined) {
306
+ report.port = { port, listening: false };
307
+ } else {
308
+ const holderName = snapshot.processes.find((proc) => proc.pid === holder.pid)?.name ?? "(unknown)";
309
+ const inTree = treePidSet?.has(holder.pid) ?? false;
310
+ report.port = {
311
+ port,
312
+ listening: true,
313
+ holderPid: holder.pid,
314
+ holderName: holderName.replace(/\.exe$/i, ""),
315
+ inTree,
316
+ };
317
+ if (!inTree) {
318
+ warnings.push(
319
+ `port ${port} is held by ${holderName.replace(/\.exe$/i, "")}(${holder.pid}) OUTSIDE this door's process tree - a leftover listener the door cannot bind past (its log would carry EADDRINUSE); find it with netstat -ano | findstr :${port}`,
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ // door.log tail triage (ISSUE-016 vocabulary made actionable).
326
+ if (report.launcherPath !== undefined) {
327
+ const liveCmd =
328
+ root !== undefined
329
+ ? snapshot.processes.find((proc) => proc.ppid === root!.pid && /^cmd\.exe$/i.test(proc.name))
330
+ : undefined;
331
+ const logPath = findLogPath(liveCmd?.commandLine, vbsText, report.launcherPath);
332
+ if (logPath === undefined) {
333
+ warnings.push("the door.log path could not be determined (neither the live launcher nor the vbs names it)");
334
+ } else {
335
+ try {
336
+ const tail = env.readTextFile(logPath).slice(-8192);
337
+ // Latest-evidence triage: the log is append-only, so a failure
338
+ // signature only counts when it lands AFTER the newest serving
339
+ // banner (a banner that follows supersedes the failure - the door
340
+ // restarted or the artifacts were regenerated; field door 8570
341
+ // carries ISSUE-016-era scar lines under a healthy banner).
342
+ const lastBanner = Math.max(
343
+ tail.lastIndexOf("serving endpoint '"),
344
+ tail.lastIndexOf("serving host door"),
345
+ );
346
+ const lastEaddrinuse = tail.lastIndexOf("EADDRINUSE");
347
+ const lastSharingViolation = tail.lastIndexOf("being used by another process");
348
+ const banner = lastBanner !== -1;
349
+ const lastListening = [...tail.matchAll(/listening: (\S+)/g)].map((m) => m[1]!).at(-1);
350
+ const eaddrinuse = lastEaddrinuse > lastBanner;
351
+ const sharingViolation = lastSharingViolation > lastBanner;
352
+ report.log = { path: logPath, banner, lastListening, eaddrinuse, sharingViolation };
353
+ if (!banner) {
354
+ warnings.push(
355
+ `no serving banner in the tail of ${logPath} - the door never reached serving in what the log remembers`,
356
+ );
357
+ }
358
+ if (eaddrinuse) {
359
+ warnings.push(
360
+ `door.log tail carries EADDRINUSE as its latest evidence - the port was already taken when the door last tried to bind; see the port row above, then restart the door by killing the launcher root (taskkill /PID <wscript-pid> /T /F) and Start-ScheduledTask -TaskName ${report.taskName}`,
361
+ );
362
+ }
363
+ if (sharingViolation) {
364
+ warnings.push(
365
+ "door.log carries 'being used by another process' as its latest evidence - the double-redirect deadlock shape (a redirect on the serve line inside the start script while the hidden launcher also redirects); regenerate the artifacts with a current ukp ('ukp serve --print-task') - the serve line must carry no redirect",
366
+ );
367
+ }
368
+ } catch {
369
+ warnings.push(`no readable door.log at ${logPath} - the door has not logged a start here`);
370
+ }
371
+ }
372
+ }
373
+
374
+ // Door-level findings.
375
+ if (report.tree === undefined) {
376
+ if (/^running$/i.test(report.taskState)) {
377
+ warnings.push(
378
+ `the task state says Running but no launcher process exists - the door crashed past the launcher's bounded retries or was killed; start it again (Start-ScheduledTask -TaskName ${report.taskName})`,
379
+ );
380
+ } else {
381
+ warnings.push(
382
+ `door not running - the task is registered but nothing holds the process tree; start it with Start-ScheduledTask -TaskName ${report.taskName}, or remove it ('ukp serve --print-task' section 5)`,
383
+ );
384
+ }
385
+ } else if (report.port?.listening === false) {
386
+ warnings.push(
387
+ `the door's process tree is alive but nothing listens on port ${port} - the serve process may be mid-retry or failed to bind; check the log tail above`,
388
+ );
389
+ }
390
+
391
+ report.status = failed ? "failed" : warnings.length > 0 ? "warning" : "ok";
392
+ return report;
393
+ }
394
+
395
+ /** Snapshot -> per-door reports. Pure apart from the injected file reader. */
396
+ export function evaluateDoorSnapshot(snapshot: DoorSystemSnapshot, env: DoorEvaluationEnv): DoorReport[] {
397
+ const reports: DoorReport[] = [];
398
+ for (const task of snapshot.tasks) {
399
+ if (!isDoorTaskAction(task.execute, task.arguments)) continue;
400
+ const report: DoorReport = { taskName: task.name, taskState: task.state, warnings: [], status: "ok" };
401
+ const resolved = resolveDoorLauncherPath(task.arguments, env.homeDir);
402
+ if (!resolved.resolved) {
403
+ report.status = "failed";
404
+ report.warnings.push(
405
+ `the task action could not be resolved to a launcher path: ${task.arguments} (only %USERPROFILE% is expanded)`,
406
+ );
407
+ } else {
408
+ report.launcherPath = resolved.path;
409
+ if (/-hidden\.vbs$/i.test(resolved.path)) {
410
+ report.scriptPath = resolved.path.replace(/-hidden\.vbs$/i, ".cmd");
411
+ }
412
+ }
413
+ reports.push(evaluateDoor(report, snapshot, env));
414
+ }
415
+ return reports.sort((a, b) => a.taskName.localeCompare(b.taskName));
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Rendering (diagnose wire vocabulary: key: value rows, status:, warning:)
420
+ // ---------------------------------------------------------------------------
421
+
422
+ export function renderDoorReport(report: DoorReport): string[] {
423
+ const lines = [
424
+ `== door: ${report.taskName} ==`,
425
+ `task: ${report.taskState}`,
426
+ ...(report.launcherPath !== undefined ? [`launcher: ${report.launcherPath}`] : []),
427
+ ];
428
+ if (report.scriptPath !== undefined) {
429
+ lines.push(`script: ${report.scriptPath}`);
430
+ }
431
+ if (report.serve !== undefined) {
432
+ const shape = report.serve.endpoint !== undefined
433
+ ? `endpoint ${report.serve.endpoint}`
434
+ : report.serve.select !== undefined
435
+ ? `subset ${report.serve.select.join(",")}`
436
+ : "host door";
437
+ const host = report.serve.host !== undefined ? `, host ${report.serve.host}` : "";
438
+ const port = report.serve.port !== undefined ? `, port ${report.serve.port}` : "";
439
+ lines.push(`serve: ${shape}${host}${port}`);
440
+ }
441
+ lines.push(
442
+ report.tree !== undefined
443
+ ? `tree: ${report.tree.map((node) => `${node.name}(${node.pid})`).join(" > ")}`
444
+ : "tree: (no launcher process)",
445
+ );
446
+ if (report.port !== undefined) {
447
+ if (!report.port.listening) {
448
+ lines.push(`port: ${report.port.port} free (nothing listens)`);
449
+ } else if (report.port.inTree) {
450
+ lines.push(`port: ${report.port.port} listening (pid ${report.port.holderPid}, inside the door tree)`);
451
+ } else {
452
+ lines.push(`port: ${report.port.port} listening (pid ${report.port.holderPid}, OUTSIDE the door tree)`);
453
+ }
454
+ }
455
+ if (report.log !== undefined) {
456
+ const tail = report.log.banner
457
+ ? report.log.lastListening !== undefined
458
+ ? `banner present (last: listening: ${report.log.lastListening})`
459
+ : "banner present"
460
+ : "no serving banner in the tail";
461
+ lines.push(`log: ${report.log.path}`, `log_tail: ${tail}`);
462
+ }
463
+ for (const warning of report.warnings) {
464
+ lines.push(`warning: ${warning}`);
465
+ }
466
+ lines.push(`status: ${report.status}`);
467
+ return lines;
468
+ }
469
+
470
+ export function renderDoorHealthReport(reports: readonly DoorReport[]): string {
471
+ if (reports.length === 0) {
472
+ return [
473
+ "no resident doors found on this host (no scheduled task launches a .ukp",
474
+ "hidden launcher). This is not an error - doors exist only where",
475
+ "'ukp serve --print-task' was applied; the ssh:// path runs none.",
476
+ "",
477
+ ].join("\n");
478
+ }
479
+ return `${reports.map((report) => renderDoorReport(report).join("\n")).join("\n\n")}\n`;
480
+ }
@@ -124,8 +124,9 @@ export interface SelfSignedIdentity {
124
124
  }
125
125
 
126
126
  /** Idempotent self-signed identity for `ukp serve --tls`: persisted under
127
- * `<serviceFolder>/.ukp/tls/` (machine-local, gitignored — the instance-uid
128
- * posture). openssl signs it because node:crypto cannot issue X.509; the
127
+ * `<serviceFolder>/.ukp/tls/` (machine-local; `init service` self-ignores
128
+ * `.ukp/` via its `.gitignore` — the instance-uid posture). openssl signs
129
+ * it because node:crypto cannot issue X.509; the
129
130
  * identity is the keypair, so losing the key loses the identity (clients
130
131
  * re-register). `extraSanEntries` (the `--tls-san` flag, already normalized
131
132
  * `IP:x`/`DNS:y`) merges into the SAN coverage; when a persisted certificate
package/src/cli.ts CHANGED
@@ -95,6 +95,10 @@ export interface CliContext {
95
95
  artifactRoot?: string;
96
96
  artifactRunId?: string;
97
97
  now?: Date;
98
+ /** Overrides the door system snapshot behind `diagnose --door` for tests. */
99
+ doorProbe?: () => import("./capabilities/door-health.ts").DoorSystemSnapshot;
100
+ /** Overrides door-file reads behind `diagnose --door` for tests. */
101
+ readTextFile?: (path: string) => string;
98
102
  }
99
103
 
100
104
  interface CliCommandResult {
@@ -353,6 +357,8 @@ export function runCli(
353
357
  currentDirectory,
354
358
  registryPath,
355
359
  resolveProvider: context.resolveProvider,
360
+ doorProbe: context.doorProbe,
361
+ readTextFile: context.readTextFile,
356
362
  }), stdout, stderr);
357
363
  }
358
364
 
@@ -8,6 +8,15 @@ import { HelpRequestError, isHelpRequest } from "./flags.ts";
8
8
  import { defaultQmdCommand } from "../capabilities/qmd.ts";
9
9
  import { resolveProposeFolder } from "../capabilities/propose.ts";
10
10
  import { rgExecutableAvailable } from "../capabilities/rg.ts";
11
+ import {
12
+ defaultDoorSystemProbe,
13
+ defaultReadDoorFile,
14
+ evaluateDoorSnapshot,
15
+ renderDoorHealthReport,
16
+ type DoorSystemProbe,
17
+ type DoorSystemSnapshot,
18
+ } from "../capabilities/door-health.ts";
19
+ import { homedir } from "node:os";
11
20
 
12
21
  export interface ProviderCheck {
13
22
  supported: boolean;
@@ -31,6 +40,15 @@ export interface DiagnoseCommandContext {
31
40
  currentDirectory: string;
32
41
  registryPath: string;
33
42
  resolveProvider?: ProviderResolver;
43
+ /** Overrides the PowerShell door snapshot for tests (--door mode). */
44
+ doorProbe?: DoorSystemProbe;
45
+ /** Overrides file reads of the operator-authored door files for tests. */
46
+ readTextFile?: (path: string) => string;
47
+ /** Overrides the %USERPROFILE% expansion root for tests (--door mode). */
48
+ homeDir?: string;
49
+ /** Overrides the door-mode platform admission for tests (--door mode);
50
+ * process.platform in production. */
51
+ platform?: string;
34
52
  }
35
53
 
36
54
  export interface DiagnoseCommandResult {
@@ -52,8 +70,11 @@ export const DIAGNOSE_SPEC: UkpCommandSpec = {
52
70
  name: "diagnose",
53
71
  summary: "check a Service or endpoints for wiring problems (manifest, provider setup)",
54
72
  group: "operations",
55
- description: "Validate a Service folder or selected registered Service endpoints.",
56
- usage: "[--endpoint <name> ... | -g]",
73
+ description: "Validate a Service folder or selected registered Service endpoints, or this host's resident doors (--door).",
74
+ usage: "[--door] [--endpoint <name> ... | -g]",
75
+ options: [
76
+ { flags: "--door", help: "check this host's resident doors (Windows scheduled tasks running the print-task hidden launcher): task state, process tree, port holder, door.log tail - read-only" },
77
+ ],
57
78
  scope: {
58
79
  endpointHelp: "validate one registered endpoint; repeat to validate multiple endpoints",
59
80
  globalHelp: "validate every endpoint in the Host Registry; takes no value",
@@ -65,6 +86,16 @@ export const DIAGNOSE_SPEC: UkpCommandSpec = {
65
86
  " Service (Manifest and provider wiring). -g validates every registered",
66
87
  " endpoint; -g cannot be combined with --endpoint.",
67
88
  "",
89
+ "Doors:",
90
+ " --door skips Service validation and checks this host's resident doors",
91
+ " (Windows): every scheduled task running the print-task hidden launcher",
92
+ " is reported with its task state, the wscript->cmd->bun process tree,",
93
+ " its port (and who holds it), and the door.log tail. Read-only -",
94
+ " repairs live in 'ukp serve --print-task' section 5 and the deployment",
95
+ " handbook. A door that is down is a finding (exit 1); a host with no",
96
+ " doors is healthy. Not combinable with --endpoint or -g; on Linux the",
97
+ " door is a systemd socket-activation unit (see the handbook).",
98
+ "",
68
99
  ].join("\n"),
69
100
  };
70
101
 
@@ -208,18 +239,55 @@ export function renderDependencyRegistryWarnings(
208
239
  }
209
240
 
210
241
  function parseDiagnoseArgs(args: readonly string[]): {
242
+ door: boolean;
211
243
  explicitEndpoints?: string[];
212
244
  global: boolean;
213
245
  warnings: string[];
214
246
  } {
215
247
  const parsed = parseKitArgs(DIAGNOSE_SPEC, args);
216
248
  return {
249
+ door: (parsed.options as { door?: boolean }).door === true,
217
250
  explicitEndpoints: parsed.scope.explicitEndpoints,
218
251
  global: parsed.scope.global,
219
252
  warnings: parsed.scope.warnings,
220
253
  };
221
254
  }
222
255
 
256
+ /** Door-mode platform admission (mirrors printTaskPreflightError: pure,
257
+ * `process.platform` in production). Linux doors are systemd units with
258
+ * their own check surface; running the Windows probe there is meaningless. */
259
+ export function doorModePlatformError(platform: string): string | undefined {
260
+ if (platform !== "win32") {
261
+ return "--door is Windows-only; on Linux the door is a systemd socket-activation unit - check it with systemctl --user status ukp-door.socket (the deployment handbook carries the recipes)";
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+ function executeDoorDiagnose(context: DiagnoseCommandContext): DiagnoseCommandResult {
267
+ const probe = context.doorProbe ?? defaultDoorSystemProbe;
268
+ const readTextFile = context.readTextFile ?? defaultReadDoorFile;
269
+ let snapshot: DoorSystemSnapshot;
270
+ try {
271
+ snapshot = probe();
272
+ } catch (error) {
273
+ return {
274
+ exitCode: 1,
275
+ stdout: "",
276
+ stderr: `ukp diagnose: door snapshot failed: ${error instanceof Error ? error.message : String(error)}\n`,
277
+ };
278
+ }
279
+ const doors = evaluateDoorSnapshot(snapshot, {
280
+ homeDir: context.homeDir ?? homedir(),
281
+ readTextFile,
282
+ });
283
+ const unhealthy = doors.some((door) => door.status !== "ok");
284
+ return {
285
+ exitCode: unhealthy ? 1 : 0,
286
+ stdout: renderDoorHealthReport(doors),
287
+ stderr: "",
288
+ };
289
+ }
290
+
223
291
  function diagnoseBinding(
224
292
  binding: RegistryBinding,
225
293
  resolveProvider: ProviderResolver | undefined,
@@ -254,6 +322,16 @@ export function executeDiagnoseCommand(
254
322
 
255
323
  try {
256
324
  const parsed = parseDiagnoseArgs(args);
325
+ if (parsed.door) {
326
+ if (parsed.explicitEndpoints !== undefined || parsed.global) {
327
+ throw new KitUsageError("--door checks this host's resident doors and cannot be combined with --endpoint or -g");
328
+ }
329
+ const gate = doorModePlatformError(context.platform ?? process.platform);
330
+ if (gate !== undefined) {
331
+ throw new Error(gate);
332
+ }
333
+ return executeDoorDiagnose(context);
334
+ }
257
335
  if (!parsed.explicitEndpoints && !parsed.global) {
258
336
  try {
259
337
  const report = diagnoseService(context.currentDirectory, context.resolveProvider);
@@ -5,7 +5,7 @@ Goal: expose registered Services from one machine and consume them from another
5
5
  Current model:
6
6
  - The ssh:// path needs nothing running on the host: each invocation wakes a loopback door over the SSH connection (it serves the host Registry fresh and self-reaps when idle). Host prerequisites: the host is ssh reachable and has ukp installed the usual way (npm i -g or bun add -g - the wake finds the standard locations).
7
7
  - The https path (LAN, public IP, or behind a reverse proxy) uses a resident door: run ukp serve under your process manager. Arbitrary consumers have no SSH credentials, so the listener itself is the trust boundary (TLS + bearer token).
8
- - Prefer host door mode: run ukp serve without --endpoint to expose every local endpoint behind one port.
8
+ - Prefer host door mode when the whole Registry is safe to declare: run ukp serve without --endpoint to expose every local endpoint behind one port. A Registry that mixes in endpoints you do not want to expose uses --select instead: ukp serve --select a,b,c keeps one port and one door for exactly the named endpoints - everything unselected is not served, routed, or declared (probing its name behaves exactly like probing one that never existed). Reserve per-endpoint --endpoint <name> doors for exposing a single endpoint to different consumers.
9
9
  - The door reads the host Registry fresh on each request, so newly registered local endpoints appear without restarting the door.
10
10
  - Declared endpoint names come from the host Service Manifest or discovery document; your registry binds each one under a local handle - the declared name by default, or --name <handle> when that name is already taken (two hosts both declaring 'notes' become your 'notes' and 'ali-notes'). --endpoint during register stays an expected-name assertion on the declared name.
11
11