@catheadowl/ukp 0.2.4 → 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.
@@ -6,6 +6,7 @@ import { resolveExternalToolCapability, EXTERNAL_PROVIDER } from "../config/exte
6
6
  import { localPathOf, readRegistry } from "../registry.ts";
7
7
  import { resolveScope } from "../scope.ts";
8
8
  import { isInsideRealRoot } from "../path-safety.ts";
9
+ import { toolRelayArgv, TOOL_NOT_FOUND_EXIT } from "../spawn-relay.ts";
9
10
 
10
11
  // rg capability (ADR-RG-001..004): an independent atomic
11
12
  // capability parallel to `search` — base lexical search over the endpoint's
@@ -15,6 +16,8 @@ import { isInsideRealRoot } from "../path-safety.ts";
15
16
  // Structured outcome per ADR 0021 — never rendered text, never exit codes.
16
17
 
17
18
  export interface RgRequest {
19
+ /** Search pattern (match/count modes). Empty only in files mode, where
20
+ * the parse layer guarantees it was omitted (ADR-RG-005 mutual exclusion). */
18
21
  query: string;
19
22
  limit: number;
20
23
  }
@@ -22,10 +25,15 @@ export interface RgRequest {
22
25
  export interface RgOptions {
23
26
  explicitEndpoints?: string[];
24
27
  global: boolean;
25
- glob?: string;
28
+ /** Ordered glob filters (repeatable top-level flag, ADR-RG-005: rg glob
29
+ * grammar lets a later glob negate with `!` — include+exclude combos). */
30
+ globs?: readonly string[];
26
31
  type?: string;
27
32
  ignoreCase?: boolean;
28
33
  count?: boolean;
34
+ /** Files enumeration mode (ADR-RG-005): true tree walk via `rg --files` —
35
+ * no content matching, so empty and binary files are visible. */
36
+ files?: boolean;
29
37
  /** Validated passthrough args (allowlist + scope protection, ADR-RG-002). */
30
38
  passthrough: readonly string[];
31
39
  /** Surface concern (propose precedent): the capability never consumes it —
@@ -72,6 +80,14 @@ export interface RgCountEntry {
72
80
  count: number;
73
81
  }
74
82
 
83
+ /** One enumerated file (files mode, ADR-RG-005): endpoint-relative path plus
84
+ * the durable slot key under the same containment-gated emission rule as
85
+ * matches (ADR 0019 stance). */
86
+ export interface RgFileEntry {
87
+ path: string;
88
+ ukp_uri?: string;
89
+ }
90
+
75
91
  export interface RgEndpointOutcome {
76
92
  name: string;
77
93
  provider: typeof EXTERNAL_PROVIDER;
@@ -91,13 +107,20 @@ export interface RgEndpointOutcome {
91
107
  matches?: RgMatch[];
92
108
  /** Count mode results (`--count`). */
93
109
  counts?: RgCountEntry[];
110
+ /** Files mode results (`--files`, sorted then capped). */
111
+ files?: RgFileEntry[];
94
112
  /** True when matches were truncated at the request limit. */
95
113
  truncated?: boolean;
96
114
  }
97
115
 
116
+ /** The run's output mode — drives tool argv, intake, and both presentation
117
+ * stages (envelope count_mode/files_mode and the human render branches). */
118
+ export type RgMode = "match" | "count" | "files";
119
+
98
120
  export interface RgResult {
99
121
  query: string;
100
122
  limit: number;
123
+ mode: RgMode;
101
124
  endpoints: RgEndpointOutcome[];
102
125
  warnings: string[];
103
126
  aggregate: RgAggregateStatus;
@@ -118,6 +141,10 @@ export class RgPlanningError extends Error {
118
141
  }
119
142
 
120
143
  export const RG_DEFAULT_LIMIT = 50;
144
+ /** Files-mode request limit default (ADR-RG-005): enumeration units are a
145
+ * single path line vs the 3-4 line match unit, so the same consumer-comfort
146
+ * calibration that set 50 for matches lands on 500 here. */
147
+ export const RG_FILES_DEFAULT_LIMIT = 500;
121
148
  export const RG_MAX_LIMIT = 1000;
122
149
 
123
150
  export function defaultRgCommand(): readonly string[] {
@@ -125,10 +152,23 @@ export function defaultRgCommand(): readonly string[] {
125
152
  }
126
153
 
127
154
  /** Base-tool availability probe (diagnose / provider resolver): true when a
128
- * ripgrep executable starts. */
155
+ * ripgrep executable starts. Memoized per process — PATH does not change
156
+ * mid-process, and server startup (the door's ServeInfo carries this field)
157
+ * must not pay the probe again per start; the win32 relay costs ~0.8s where
158
+ * the direct spawn was Defender-taxed ~5s (2026-09-22 finding, see
159
+ * spawn-relay.ts). The relay's not-found guard exits TOOL_NOT_FOUND_EXIT,
160
+ * which no real ripgrep invocation produces (rg exits 0/1/2 only). */
161
+ let rgAvailabilityMemo: boolean | undefined;
162
+
129
163
  export function rgExecutableAvailable(): boolean {
130
- const result = spawnSync("rg", ["--version"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
131
- return !result.error;
164
+ if (rgAvailabilityMemo === undefined) {
165
+ const probeArgv = process.platform === "win32"
166
+ ? toolRelayArgv(["rg", "--version"], { notFoundGuard: true })
167
+ : ["rg", "--version"];
168
+ const result = spawnSync(probeArgv[0]!, probeArgv.slice(1), { encoding: "utf8", windowsHide: true, timeout: 5_000 });
169
+ rgAvailabilityMemo = !result.error && result.status !== TOOL_NOT_FOUND_EXIT;
170
+ }
171
+ return rgAvailabilityMemo;
132
172
  }
133
173
 
134
174
  export function rgTimeoutMs(): number {
@@ -142,8 +182,9 @@ export function rgTimeoutMs(): number {
142
182
  // Passthrough validation (ADR-RG-002): allowlist + scope protection.
143
183
  // ---------------------------------------------------------------------------
144
184
 
145
- /** Value flags consume the next token (or an attached value: `-C2`). */
146
- const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "-t", "--type", "--max-filesize"]);
185
+ /** Value flags consume the next token (or an attached value: `-C2`). `--iglob`
186
+ * joined with ADR-RG-005 (case-insensitive glob variant, probe friction 2/8). */
187
+ const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "--iglob", "-t", "--type", "--max-filesize"]);
147
188
  const PASSTHROUGH_BOOL_FLAGS = new Set(["-i", "--ignore-case", "-S", "--smart-case", "-s", "--case-sensitive", "-w", "--word-regexp", "-F", "--fixed-strings", "-v", "--invert-match", "-U", "--multiline", "--no-ignore", "--hidden", "--no-messages", "--column", "--no-heading"]);
148
189
 
149
190
  /** Validates passthrough args: allowlist membership + scope protection.
@@ -234,13 +275,15 @@ function toUkpUri(endpointName: string, path: string): string {
234
275
  * provider-location mapping. A miss silently drops the `ukp_uri` field
235
276
  * (the match and its `path` stay): rg output is tool output, not user
236
277
  * error. Output-side filtering is the second line of defense; the
237
- * passthrough allowlist (no path operands) is the first. */
238
- function ukpUriIfInside(endpointFolder: string, endpointName: string, path: string): string | undefined {
278
+ * passthrough allowlist (no path operands) is the first. The folder's
279
+ * realpath is passed in pre-resolved (per endpoint, not per entry — review
280
+ * 2026-09-22: per-file resolution paid 2 realpaths/file on exactly the
281
+ * win32 Defender-taxed path); an unresolvable folder emits nothing. */
282
+ function ukpUriIfInside(folderReal: string | undefined, endpointFolder: string, endpointName: string, path: string): string | undefined {
283
+ if (folderReal === undefined) return undefined;
239
284
  const absolute = resolve(endpointFolder, ...path.replace(/\\/g, "/").split("/"));
240
- let folderReal: string;
241
285
  let targetReal: string;
242
286
  try {
243
- folderReal = realpathSync(endpointFolder);
244
287
  targetReal = realpathSync(absolute);
245
288
  } catch {
246
289
  return undefined;
@@ -259,6 +302,16 @@ interface RgJsonEvent {
259
302
  }
260
303
 
261
304
  function rgToolArgs(parsed: ParsedRg): string[] {
305
+ if (parsed.options.files) {
306
+ // Files mode (ADR-RG-005): true tree walk — rg lists paths without
307
+ // reading content, so empty and binary files are visible. No `-e`:
308
+ // the parse layer guarantees no pattern rides along.
309
+ const args: string[] = ["--files", "--no-messages"];
310
+ for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
311
+ if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
312
+ args.push(...parsed.options.passthrough);
313
+ return args;
314
+ }
262
315
  const args: string[] = ["--no-heading", "--no-messages"];
263
316
  if (parsed.options.count) {
264
317
  args.push("--count");
@@ -266,7 +319,7 @@ function rgToolArgs(parsed: ParsedRg): string[] {
266
319
  args.push("--json");
267
320
  }
268
321
  if (parsed.options.ignoreCase) args.push("-i");
269
- if (parsed.options.glob !== undefined) args.push("--glob", parsed.options.glob);
322
+ for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
270
323
  if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
271
324
  args.push(...parsed.options.passthrough);
272
325
  args.push("-e", parsed.request.query);
@@ -280,6 +333,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
280
333
  validateRgPassthrough(parsed.options.passthrough);
281
334
  const { plan, warnings } = planRg(parsed, context);
282
335
  const command = context.rgCommand ?? defaultRgCommand();
336
+ const mode: RgMode = parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match";
283
337
  const endpoints: RgEndpointOutcome[] = [];
284
338
  let succeeded = false;
285
339
  let failed = false;
@@ -293,13 +347,36 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
293
347
  continue;
294
348
  }
295
349
 
350
+ // Lazy once-per-endpoint folder realpath for the uri emission rule: the
351
+ // folder cannot change mid-run, and a skip/fail path never pays for it.
352
+ // `null` marks "not attempted yet" vs `undefined` = "attempted, failed"
353
+ // (a failed resolution emits no uris without re-attempting per entry).
354
+ let endpointFolderReal: string | undefined | null = null;
355
+ const folderRealpath = (): string | undefined => {
356
+ if (endpointFolderReal === null) {
357
+ try {
358
+ endpointFolderReal = realpathSync(endpoint.folder);
359
+ } catch {
360
+ endpointFolderReal = undefined;
361
+ }
362
+ }
363
+ return endpointFolderReal;
364
+ };
365
+
296
366
  // Explicit search root: with no path operand rg searches stdin instead
297
367
  // of the cwd whenever stdin is not a tty (ISSUE-013 root cause,
298
368
  // confirmed on the ali repro host: spawned rg read an empty pipe and
299
369
  // exited 1 with zero traversal; tty and /dev/null stdin walk the cwd).
300
370
  // "." keeps rg's rendered paths endpoint-relative; the "./" prefix is
301
- // stripped at both intakes below.
302
- const result = spawnSync(command[0]!, [...command.slice(1), ...rgToolArgs(parsed), "."], {
371
+ // stripped at both intakes below. Win32: the real-tool invocation routes
372
+ // through the spawn relay (Defender taxes the direct bun-to-rg.exe edge
373
+ // ~5s — 2026-09-22 finding, see spawn-relay.ts); the injected test
374
+ // command is never wrapped, and the relay's not-found guard maps a
375
+ // missing rg onto the same skip path the spawn error always took.
376
+ const runArgv = process.platform === "win32" && context.rgCommand === undefined
377
+ ? toolRelayArgv([...command, ...rgToolArgs(parsed), "."], { notFoundGuard: true })
378
+ : [command[0]!, ...command.slice(1), ...rgToolArgs(parsed), "."];
379
+ const result = spawnSync(runArgv[0]!, runArgv.slice(1), {
303
380
  cwd: endpoint.folder,
304
381
  encoding: "utf8",
305
382
  windowsHide: true,
@@ -307,7 +384,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
307
384
  timeout: rgTimeoutMs(),
308
385
  });
309
386
 
310
- if (result.error) {
387
+ if (result.error || result.status === TOOL_NOT_FOUND_EXIT) {
311
388
  // ADR-RG-003/R-7: a missing base tool is a degradation, not a fault —
312
389
  // skipped + warning, the run continues with other endpoints.
313
390
  warnings.push(`endpoint '${endpoint.name}' rg unavailable: rg executable is not available`);
@@ -340,6 +417,42 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
340
417
  continue;
341
418
  }
342
419
 
420
+ if (mode === "files") {
421
+ // Plain path-per-line intake. The full list is sorted BEFORE the
422
+ // client-side cap so truncation yields a deterministic window (the
423
+ // first N paths in lexicographic order, ADR-RG-005) rather than an
424
+ // arbitrary subset of rg's parallel-walk order.
425
+ const paths = (result.stdout ?? "")
426
+ .split(/\r?\n/)
427
+ .filter((line) => line.length > 0)
428
+ .map((line) => line.replace(/\\/g, "/").replace(/^\.\//, ""))
429
+ .sort();
430
+ const files: RgFileEntry[] = [];
431
+ let truncated = false;
432
+ for (const path of paths) {
433
+ if (files.length >= parsed.request.limit) {
434
+ truncated = true;
435
+ break;
436
+ }
437
+ const entry: RgFileEntry = { path };
438
+ const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, path);
439
+ if (uri !== undefined) entry.ukp_uri = uri;
440
+ files.push(entry);
441
+ }
442
+ if (files.length === 0 && !truncated) {
443
+ // An empty enumeration (empty folder or everything filtered out) is
444
+ // a successful result — same D-036 stance as a no-match search.
445
+ succeeded = true;
446
+ outcome.status = "no_matches";
447
+ } else {
448
+ succeeded = true;
449
+ outcome.status = "succeeded";
450
+ outcome.files = files;
451
+ if (truncated) outcome.truncated = true;
452
+ }
453
+ continue;
454
+ }
455
+
343
456
  if (parsed.options.count) {
344
457
  const counts: RgCountEntry[] = [];
345
458
  for (const line of (result.stdout ?? "").split(/\r?\n/)) {
@@ -386,7 +499,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
386
499
  const firstLine = text.split(/\r?\n/)[0] ?? "";
387
500
  if (firstLine.length > 0) entry.text = firstLine.length > 240 ? `${firstLine.slice(0, 240)}...` : firstLine;
388
501
  }
389
- const uri = ukpUriIfInside(endpoint.folder, endpoint.name, entry.path);
502
+ const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, entry.path);
390
503
  if (uri !== undefined) entry.ukp_uri = uri;
391
504
  matches.push(entry);
392
505
  }
@@ -406,6 +519,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
406
519
  return {
407
520
  query: parsed.request.query,
408
521
  limit: parsed.request.limit,
522
+ mode,
409
523
  endpoints,
410
524
  warnings,
411
525
  aggregate: interrupted
@@ -426,35 +540,41 @@ export interface RgEnvelope {
426
540
  schema: "ukp.rg.v1";
427
541
  command: "rg";
428
542
  capability: "rg";
429
- query: string;
543
+ /** Omitted in files mode (ADR-RG-005): there is no pattern to echo. */
544
+ query?: string;
430
545
  limit: number;
431
546
  count_mode: boolean;
547
+ files_mode: boolean;
432
548
  endpoints: Array<{
433
549
  name: string;
434
550
  provider: string;
435
551
  status: string;
436
552
  match_count?: number;
553
+ file_count?: number;
437
554
  truncated?: boolean;
438
555
  message?: string;
439
556
  matches?: RgMatch[];
440
557
  counts?: RgCountEntry[];
558
+ files?: RgFileEntry[];
441
559
  }>;
442
560
  warnings: string[];
443
561
  }
444
562
 
445
- export function projectRgEnvelope(result: RgResult, countMode: boolean): RgEnvelope {
563
+ export function projectRgEnvelope(result: RgResult): RgEnvelope {
446
564
  return {
447
565
  schema: "ukp.rg.v1",
448
566
  command: "rg",
449
567
  capability: "rg",
450
- query: result.query,
568
+ ...(result.mode === "files" ? {} : { query: result.query }),
451
569
  limit: result.limit,
452
- count_mode: countMode,
570
+ count_mode: result.mode === "count",
571
+ files_mode: result.mode === "files",
453
572
  endpoints: result.endpoints.map((endpoint) => ({
454
573
  name: endpoint.name,
455
574
  provider: endpoint.provider,
456
575
  status: endpoint.status ?? "failed",
457
576
  ...(endpoint.matches !== undefined ? { match_count: endpoint.matches.length, matches: endpoint.matches } : {}),
577
+ ...(endpoint.files !== undefined ? { file_count: endpoint.files.length, files: endpoint.files } : {}),
458
578
  ...(endpoint.counts !== undefined ? { counts: endpoint.counts } : {}),
459
579
  ...(endpoint.truncated !== undefined ? { truncated: endpoint.truncated } : {}),
460
580
  ...(endpoint.message !== undefined ? { message: endpoint.message } : {}),
@@ -480,7 +600,14 @@ export function renderRgHuman(result: RgResult): RgHumanView {
480
600
  continue;
481
601
  }
482
602
  if (endpoint.status === "no_matches") {
483
- lines.push("(no matches)");
603
+ lines.push(result.mode === "files" ? "(no files)" : "(no matches)");
604
+ continue;
605
+ }
606
+ if (endpoint.files !== undefined) {
607
+ // Exhibit form (probe 20260922, zero misreads): one line per file,
608
+ // the durable uri when containment allows it, the raw path otherwise.
609
+ for (const file of endpoint.files) lines.push(file.ukp_uri ?? file.path);
610
+ if (endpoint.truncated) lines.push(`(truncated at the result limit ${result.limit}; raise --limit)`);
484
611
  continue;
485
612
  }
486
613
  if (endpoint.counts !== undefined) {
@@ -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
@@ -24,7 +24,7 @@ import { executeSearchCommand, SEARCH_SPEC } from "./commands/search.ts";
24
24
  export { renderSearchHelp } from "./commands/search.ts";
25
25
  export { renderDiagnoseHelp } from "./commands/diagnose.ts";
26
26
  export { renderReadHelp } from "./commands/read.ts";
27
- export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
27
+ export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRgGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
28
28
  export { renderInitHelp, renderInitServiceHelp } from "./commands/init.ts";
29
29
  export { renderInspectHelp } from "./commands/inspect.ts";
30
30
  export { renderUpdateHelp } from "./commands/update.ts";
@@ -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,13 +5,15 @@ 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
 
12
12
  Owner path over SSH (zero ceremony - nothing to start on the host):
13
13
  1. Prerequisites (once): the host accepts your SSH key and has ukp installed the usual way
14
14
  ssh <host> ukp --help
15
+ Peek at the door before importing: ssh <host> ukp list shows the endpoint
16
+ names and capabilities the host declares
15
17
 
16
18
  2. On the consuming machine, import the door (the first call wakes it)
17
19
  ukp register --url ssh://<host>
@@ -45,7 +47,7 @@ Use this when you deliberately want to expose only one endpoint instead of the w
45
47
  ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
46
48
 
47
49
  Name and identity:
48
- - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken).
50
+ - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken). That document is public - curl it before registering to preview the endpoint names and capabilities the door declares (self-signed doors: curl -k).
49
51
  - --endpoint <name> on register asserts the expected name; if discovery declares a different name, registration fails.
50
52
  - A host door can import everything, --select a comma-separated subset, or --endpoint exactly one endpoint.
51
53
  - Self-signed HTTPS certificates are pinned on first registration; compare the pinned fingerprint with the serve banner on untrusted networks.
@@ -0,0 +1,35 @@
1
+ UKP rg quickstart
2
+
3
+ Goal: search or enumerate a Service's own files with ripgrep - no index, no declaration, real-time.
4
+
5
+ Model:
6
+ - rg is the lexical baseline: it scans files at call time, so it works on every registered endpoint. 'ukp search' is the indexed path and needs the Service to declare a provider.
7
+ - Output is ukp:// references that 'ukp read' consumes directly.
8
+ - Three modes: match (default), --count (per-file totals), --files (enumeration).
9
+
10
+ 1. Search
11
+ ukp rg --endpoint <name> "pattern"
12
+ -i is case-insensitive; --glob narrows by name; --limit raises the cap (default 50 matches).
13
+
14
+ 2. Enumerate files (no pattern)
15
+ ukp rg --endpoint <name> --files
16
+ A true tree walk, not a content match: empty and binary files are listed too.
17
+ Narrow by name with --glob, by kind with --type. Output is sorted; the
18
+ cap defaults to 500 (--limit to change).
19
+
20
+ 3. Glob semantics
21
+ --glob follows ripgrep/gitignore rules: a glob without "/" matches the
22
+ file name at any depth ("*.md"); a glob containing "/" anchors to the
23
+ endpoint-relative path ("docs/*"). Matching is case-sensitive; pass
24
+ -- --iglob "<glob>" after '--' for a case-insensitive variant.
25
+ --glob repeats: a leading "!" excludes, so "*.md" plus "!docs/*" is
26
+ markdown outside docs/.
27
+
28
+ 4. Visibility (every rg mode)
29
+ Hidden files (dot-prefixed, including the .ukp/ wiring) are skipped by
30
+ default. Add -- --hidden after '--' to include them; -uu also lifts
31
+ .gitignore rules. 'ukp read' can address hidden files directly either way.
32
+
33
+ Remember:
34
+ - Exact flags and the passthrough allowlist live in 'ukp rg --help'.
35
+ - Remote endpoints behave the same: one command drives local and door-registered endpoints alike.
@@ -20,10 +20,11 @@ const GUIDE_CONTENT_URL = new URL("./guide-content/", import.meta.url);
20
20
  * `ukp guide --help` render the same summaries (blank-agent sweep 2026-09-11:
21
21
  * the inversion of root help describing guides better than guide's own help
22
22
  * was a confirmed doc-gap). */
23
- export const GUIDE_TOPICS: ReadonlyArray<readonly [topic: string, summary: string]> = [
23
+ export const GUIDE_TOPICS: ReadonlyArray<readonly [string, string]> = [
24
24
  ["service", "first Service setup with provider-free nav/read/rg baseline"],
25
25
  ["service qmd", "provider setup for the default QMD provider"],
26
26
  ["client", "use registered Services by default from a workspace; set your default scope here"],
27
+ ["rg", "lexical search and file enumeration: modes, glob semantics, visibility tiers"],
27
28
  ["remote", "serve and consume remote endpoints with a host door, ssh, or TLS"],
28
29
  ["propose", "submit idempotent change proposals to a Service"],
29
30
  ];
@@ -38,7 +39,7 @@ export const GUIDE_SPEC: UkpCommandSpec = {
38
39
  description: "Show short operational guides.",
39
40
  usage: "<topic> [subtopic]",
40
41
  arguments: [
41
- { name: "topic", required: true, help: "guide topic: service | service qmd | client | remote | propose" },
42
+ { name: "topic", required: true, help: "guide topic: service | service qmd | client | rg | remote | propose" },
42
43
  { name: "subtopic", help: "provider subtopic for a topic, e.g. 'qmd' as in 'ukp guide service qmd'" },
43
44
  ],
44
45
  strictArguments: true,
@@ -72,17 +73,19 @@ export function executeGuideCommand(args: readonly string[]): GuideCommandResult
72
73
  return { exitCode: 0, stdout: renderServiceGuide(), stderr: "" };
73
74
  }
74
75
  if (subtopic) {
75
- throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, remote, propose`);
76
+ throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, rg, remote, propose`);
76
77
  }
77
78
  switch (topic) {
78
79
  case "client":
79
80
  return { exitCode: 0, stdout: renderClientGuide(), stderr: "" };
81
+ case "rg":
82
+ return { exitCode: 0, stdout: renderRgGuide(), stderr: "" };
80
83
  case "remote":
81
84
  return { exitCode: 0, stdout: renderRemoteGuide(), stderr: "" };
82
85
  case "propose":
83
86
  return { exitCode: 0, stdout: renderProposeGuide(), stderr: "" };
84
87
  default:
85
- throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, remote, propose`);
88
+ throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, rg, remote, propose`);
86
89
  }
87
90
  } catch (error) {
88
91
  if (error instanceof HelpRequestError) {
@@ -115,6 +118,10 @@ export function renderClientGuide(): string {
115
118
  return readGuideContent("client.txt");
116
119
  }
117
120
 
121
+ export function renderRgGuide(): string {
122
+ return readGuideContent("rg.txt");
123
+ }
124
+
118
125
  export function renderRemoteGuide(): string {
119
126
  return readGuideContent("remote.txt");
120
127
  }