@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.
- package/README.md +15 -7
- package/docs/remote-deployment.md +101 -16
- package/package.json +1 -1
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/remote-client.ts +201 -21
- package/src/capabilities/rg.ts +147 -20
- package/src/capabilities/tls-identity.ts +3 -2
- package/src/cli.ts +7 -1
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/remote.txt +4 -2
- package/src/commands/guide-content/rg.txt +35 -0
- package/src/commands/guide.ts +11 -4
- package/src/commands/init.ts +27 -0
- package/src/commands/rg.ts +45 -14
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +101 -19
- package/src/server.ts +113 -65
- package/src/spawn-relay.ts +57 -0
package/src/capabilities/rg.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
128
|
-
*
|
|
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
|
|
package/src/commands/diagnose.ts
CHANGED
|
@@ -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.
|
package/src/commands/guide.ts
CHANGED
|
@@ -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 [
|
|
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
|
}
|