@catheadowl/ukp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runRg,
|
|
3
|
+
projectRgEnvelope,
|
|
4
|
+
renderRgHuman,
|
|
5
|
+
RG_DEFAULT_LIMIT,
|
|
6
|
+
RG_MAX_LIMIT,
|
|
7
|
+
RgPlanningError,
|
|
8
|
+
RgUsageError,
|
|
9
|
+
validateRgPassthrough,
|
|
10
|
+
type ParsedRg,
|
|
11
|
+
type RgAggregateStatus,
|
|
12
|
+
type RgContext,
|
|
13
|
+
type RgEndpointOutcome,
|
|
14
|
+
type RgResult,
|
|
15
|
+
} from "../capabilities/rg.ts";
|
|
16
|
+
import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
|
|
17
|
+
import {
|
|
18
|
+
createTransportPool,
|
|
19
|
+
fetchDiscoveryDocument,
|
|
20
|
+
remoteRg,
|
|
21
|
+
resolveRemoteToken,
|
|
22
|
+
} from "../capabilities/remote-client.ts";
|
|
23
|
+
import { isRemoteBinding, readRegistry, type RegistryBinding } from "../registry.ts";
|
|
24
|
+
import { resolveScope, ScopeError } from "../scope.ts";
|
|
25
|
+
import { ManifestError } from "../config/manifest.ts";
|
|
26
|
+
import { KitUsageError, parseKitArgs, renderKitHelp, renderKitUsageError, type UkpCommandSpec } from "./kit.ts";
|
|
27
|
+
import { HelpRequestError, isHelpRequest } from "./flags.ts";
|
|
28
|
+
|
|
29
|
+
/** CLI-owned command result shape (ADR 0021). */
|
|
30
|
+
export interface RgCommandResult {
|
|
31
|
+
exitCode: number;
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aggregate classification → exit code (ADR 0021 / D-036 semantics). */
|
|
37
|
+
const RG_EXIT_BY_AGGREGATE: Record<RgAggregateStatus, number> = {
|
|
38
|
+
"succeeded": 0,
|
|
39
|
+
"failed": 1,
|
|
40
|
+
"no-success": 1,
|
|
41
|
+
"cancelled": 130,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Single-source command spec (ADR 0024). Options-only form (like search):
|
|
45
|
+
* `-c`/`-g` declared inline so the rendered option order and the error
|
|
46
|
+
* precedence (limit validation before duplicate warnings, excess before
|
|
47
|
+
* conflict) stay identical with the pre-kit command. */
|
|
48
|
+
export const RG_SPEC: UkpCommandSpec = {
|
|
49
|
+
name: "rg",
|
|
50
|
+
summary: "grep raw endpoint files with ripgrep (no index or declaration needed; results as ukp:// references)",
|
|
51
|
+
group: "endpoint",
|
|
52
|
+
description:
|
|
53
|
+
"Run base lexical search (ripgrep) across one or more Service endpoints. "
|
|
54
|
+
+ "Available on every registered endpoint by default; results are shaped into ukp:// references that 'ukp read' consumes directly. "
|
|
55
|
+
+ "For indexed/semantic search use 'ukp search'.",
|
|
56
|
+
usage: "[--endpoint <name> ... | -g] <pattern> [--limit <1-1000>] [--count] [--glob <glob>] [--type <type>] [-i] [-- <rg flags>]",
|
|
57
|
+
arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell" }],
|
|
58
|
+
options: [
|
|
59
|
+
{ flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
|
|
60
|
+
{ flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
|
|
61
|
+
{ flags: "--limit <1-1000>", help: `maximum matches per run (default: ${RG_DEFAULT_LIMIT})` },
|
|
62
|
+
{ flags: "--count", help: "count mode: per-file match counts instead of matches" },
|
|
63
|
+
{ flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\")" },
|
|
64
|
+
{ flags: "--type <type>", help: "file type filter passed to rg (e.g. md, py)" },
|
|
65
|
+
{ flags: "-i", help: "case-insensitive search" },
|
|
66
|
+
{ flags: "--json", help: "emit the structured response envelope" },
|
|
67
|
+
],
|
|
68
|
+
helpSuffix: [
|
|
69
|
+
"",
|
|
70
|
+
"Passthrough:",
|
|
71
|
+
" After '--', rg native flags are passed through on an allowlist",
|
|
72
|
+
" (-A/-B/-C/-m/--glob/--type/--max-filesize and common boolean flags).",
|
|
73
|
+
" Path operands and output-changing flags (--json, -r, --pre, --config)",
|
|
74
|
+
" are rejected: the endpoint selector owns scope, UKP owns the output.",
|
|
75
|
+
"",
|
|
76
|
+
"Result scope:",
|
|
77
|
+
" With no --endpoint or -g, the workspace default scope applies: the",
|
|
78
|
+
" Client Config's default endpoints ('ukp inspect' shows the resolved",
|
|
79
|
+
" scope); with no Client Config, every registered endpoint. -g cannot",
|
|
80
|
+
" be combined with --endpoint. 'Available on",
|
|
81
|
+
" every registered endpoint' refers to capability availability, not this",
|
|
82
|
+
" default scope.",
|
|
83
|
+
" rg scans each endpoint's own files (same visibility root as read/file).",
|
|
84
|
+
" 'ukp search' covers indexed search where the Service declares a search",
|
|
85
|
+
" capability provider.",
|
|
86
|
+
"",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
interface RgCommandOptions extends Record<string, unknown> {
|
|
91
|
+
endpoint?: string[];
|
|
92
|
+
g?: boolean;
|
|
93
|
+
limit?: string;
|
|
94
|
+
count?: boolean;
|
|
95
|
+
glob?: string;
|
|
96
|
+
type?: string;
|
|
97
|
+
i?: boolean;
|
|
98
|
+
json?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseRgArgs(args: readonly string[]): ParsedRg {
|
|
102
|
+
const parsed = parseKitArgs<RgCommandOptions>(RG_SPEC, args);
|
|
103
|
+
const explicit: string[] = [];
|
|
104
|
+
const warnings: string[] = [];
|
|
105
|
+
|
|
106
|
+
// Command-side semantic checks, in the pre-kit order: limit validation,
|
|
107
|
+
// duplicate warnings, excess positional, emptiness, conflict.
|
|
108
|
+
let limit = RG_DEFAULT_LIMIT;
|
|
109
|
+
if (parsed.options.limit !== undefined) {
|
|
110
|
+
if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
|
|
111
|
+
limit = Number(parsed.options.limit);
|
|
112
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > RG_MAX_LIMIT) {
|
|
113
|
+
throw new KitUsageError(`--limit must be between 1 and ${RG_MAX_LIMIT}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const endpoint of parsed.options.endpoint ?? []) {
|
|
118
|
+
if (explicit.includes(endpoint)) warnings.push(`duplicate endpoint '${endpoint}' ignored`);
|
|
119
|
+
else explicit.push(endpoint);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const [pattern, unexpected] = parsed.positionals;
|
|
123
|
+
if (unexpected !== undefined) {
|
|
124
|
+
throw new KitUsageError(
|
|
125
|
+
`unexpected argument '${unexpected}'; rg accepts exactly one pattern. Use '--endpoint <name>' to select an endpoint; '-g' takes no value.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (pattern === undefined || pattern.length === 0) throw new KitUsageError("rg pattern must be non-empty");
|
|
129
|
+
if (parsed.options.g && explicit.length > 0) {
|
|
130
|
+
throw new KitUsageError("--endpoint and -g cannot be used together");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
request: { query: pattern, limit },
|
|
135
|
+
options: {
|
|
136
|
+
explicitEndpoints: explicit.length > 0 ? explicit : undefined,
|
|
137
|
+
global: parsed.options.g ?? false,
|
|
138
|
+
...(parsed.options.glob === undefined ? {} : { glob: parsed.options.glob }),
|
|
139
|
+
...(parsed.options.type === undefined ? {} : { type: parsed.options.type }),
|
|
140
|
+
...(parsed.options.i === true ? { ignoreCase: true } : {}),
|
|
141
|
+
...(parsed.options.count === true ? { count: true } : {}),
|
|
142
|
+
...(parsed.options.json === true ? { json: true } : {}),
|
|
143
|
+
passthrough: [],
|
|
144
|
+
},
|
|
145
|
+
warnings,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Splits argv at the first `--`; everything after is rg passthrough
|
|
150
|
+
* (validated against the allowlist, ADR-RG-002). */
|
|
151
|
+
export function splitRgPassthrough(args: readonly string[]): { commandArgs: string[]; passthrough: string[] } {
|
|
152
|
+
const separator = args.indexOf("--");
|
|
153
|
+
if (separator === -1) return { commandArgs: [...args], passthrough: [] };
|
|
154
|
+
return {
|
|
155
|
+
commandArgs: args.slice(0, separator),
|
|
156
|
+
passthrough: args.slice(separator + 1),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function executeRgCommand(
|
|
161
|
+
args: readonly string[],
|
|
162
|
+
context: RgContext,
|
|
163
|
+
): RgCommandResult | Promise<RgCommandResult> {
|
|
164
|
+
if (isHelpRequest(args)) {
|
|
165
|
+
return { exitCode: 0, stdout: renderRgHelp(), stderr: "" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { commandArgs, passthrough } = splitRgPassthrough(args);
|
|
169
|
+
let parsed: ParsedRg;
|
|
170
|
+
try {
|
|
171
|
+
// Validate passthrough before parsing so a rejected flag surfaces as a
|
|
172
|
+
// usage error (exit 2) before any endpoint work starts.
|
|
173
|
+
validateRgPassthrough(passthrough);
|
|
174
|
+
parsed = parseRgArgs(commandArgs);
|
|
175
|
+
parsed.options.passthrough = passthrough;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof HelpRequestError) {
|
|
178
|
+
return { exitCode: 0, stdout: renderRgHelp(), stderr: "" };
|
|
179
|
+
}
|
|
180
|
+
if (error instanceof KitUsageError || error instanceof RgUsageError) {
|
|
181
|
+
// RgUsageError: capability-side passthrough allowlist validation
|
|
182
|
+
// classifies as usage too — same rendering, exit 2.
|
|
183
|
+
return { exitCode: 2, stdout: "", stderr: renderRgUsageError(error.message) };
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Remote-in-scope detection (sync, ukp_remote W6): mirrors search's
|
|
190
|
+
// conditional-async seam — local endpoints keep the exact sync runRg
|
|
191
|
+
// path, remote endpoints run over the wire in the mixed driver.
|
|
192
|
+
const registry = readRegistry(context.registryPath);
|
|
193
|
+
const scope = resolveScope({
|
|
194
|
+
currentDirectory: context.currentDirectory,
|
|
195
|
+
registry,
|
|
196
|
+
explicitEndpoints: parsed.options.explicitEndpoints,
|
|
197
|
+
global: parsed.options.global,
|
|
198
|
+
});
|
|
199
|
+
const remotes = scope.bindings.filter(isRemoteBinding);
|
|
200
|
+
if (remotes.length === 0) {
|
|
201
|
+
return renderRgCommandResult(parsed, runRg(parsed, context));
|
|
202
|
+
}
|
|
203
|
+
// The mixed driver starts with a SYNC local runRg whose typed errors
|
|
204
|
+
// (ScopeError/RgPlanningError/ManifestError) would otherwise escape the
|
|
205
|
+
// try/catch as a promise rejection — the same catch renders both paths.
|
|
206
|
+
return executeMixedRg(parsed, context, scope, remotes).catch(renderRgCommandError);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return renderRgCommandError(error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Error rendering shared by the sync path and the mixed driver's rejection
|
|
213
|
+
* tail: typed planning/scope/manifest failures get the classified renders,
|
|
214
|
+
* anything else the generic `ukp rg:` line. */
|
|
215
|
+
function renderRgCommandError(error: unknown): RgCommandResult {
|
|
216
|
+
if (error instanceof ScopeError) {
|
|
217
|
+
return {
|
|
218
|
+
exitCode: 1,
|
|
219
|
+
stdout: "",
|
|
220
|
+
stderr: `ukp rg: ${error.message}\nRun 'ukp list' to inspect registrations and 'ukp register' from a Service folder, then retry.\n`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (error instanceof RgPlanningError) {
|
|
224
|
+
return { exitCode: 1, stdout: "", stderr: `ukp rg: ${error.message}\n` };
|
|
225
|
+
}
|
|
226
|
+
if (error instanceof ManifestError) {
|
|
227
|
+
return { exitCode: 1, stdout: "", stderr: `ukp rg: ${error.message}\n` };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
exitCode: 1,
|
|
231
|
+
stdout: "",
|
|
232
|
+
stderr: `ukp rg: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderRgCommandResult(parsed: ParsedRg, result: RgResult): RgCommandResult {
|
|
237
|
+
const view = renderRgHuman(result);
|
|
238
|
+
if (parsed.options.json === true) {
|
|
239
|
+
return {
|
|
240
|
+
exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
|
|
241
|
+
stdout: `${JSON.stringify(projectRgEnvelope(result, parsed.options.count === true), null, 2)}\n`,
|
|
242
|
+
stderr: view.diagnostics,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
|
|
247
|
+
stdout: view.body,
|
|
248
|
+
stderr: view.diagnostics,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Mixed local+remote execution (ukp_remote W6, mirrors search's driver):
|
|
253
|
+
* locals via the unchanged sync runRg narrowed to local names, remotes via
|
|
254
|
+
* discovery-check + GET /v1/rg, merged in scope order under the D-036
|
|
255
|
+
* aggregation. */
|
|
256
|
+
async function executeMixedRg(
|
|
257
|
+
parsed: ParsedRg,
|
|
258
|
+
context: RgContext,
|
|
259
|
+
scope: ReturnType<typeof resolveScope>,
|
|
260
|
+
remotes: RegistryBinding[],
|
|
261
|
+
): Promise<RgCommandResult> {
|
|
262
|
+
const remoteNames = new Set(remotes.map((binding) => binding.name));
|
|
263
|
+
const localNames = scope.bindings.filter((binding) => !remoteNames.has(binding.name)).map((binding) => binding.name);
|
|
264
|
+
const baseWarnings: string[] = [...parsed.warnings, ...scope.warnings];
|
|
265
|
+
|
|
266
|
+
let localResult: RgResult | undefined;
|
|
267
|
+
if (localNames.length > 0) {
|
|
268
|
+
localResult = runRg(
|
|
269
|
+
{
|
|
270
|
+
...parsed,
|
|
271
|
+
options: { ...parsed.options, explicitEndpoints: localNames, global: false },
|
|
272
|
+
warnings: [...baseWarnings],
|
|
273
|
+
},
|
|
274
|
+
context,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const warnings: string[] = localResult !== undefined ? [...localResult.warnings] : [...baseWarnings];
|
|
278
|
+
// An interrupted local run cancels the remaining endpoints — runRg's
|
|
279
|
+
// interrupt semantics extend across the mixed driver (remotes never start).
|
|
280
|
+
const interrupted = localResult?.endpoints.some((outcome) => outcome.status === "interrupted") ?? false;
|
|
281
|
+
|
|
282
|
+
const remoteOutcomes = new Map<string, RgEndpointOutcome>();
|
|
283
|
+
// One transport pool for the whole invocation (W7 / O-5): same-origin door
|
|
284
|
+
// bindings share a single ssh tunnel instead of one per row.
|
|
285
|
+
const pool = createTransportPool({ registryPath: context.registryPath });
|
|
286
|
+
try {
|
|
287
|
+
for (const binding of remotes) {
|
|
288
|
+
if (interrupted) {
|
|
289
|
+
remoteOutcomes.set(binding.name, { name: binding.name, provider: EXTERNAL_PROVIDER, status: "cancelled" });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const token = resolveRemoteToken(binding);
|
|
293
|
+
try {
|
|
294
|
+
const transport = await pool.acquire(binding);
|
|
295
|
+
const discovery = await fetchDiscoveryDocument(binding, transport, token);
|
|
296
|
+
warnings.push(...discovery.warnings);
|
|
297
|
+
if (discovery.bearerRequired && token === undefined) {
|
|
298
|
+
warnings.push(
|
|
299
|
+
`endpoint '${binding.name}' requires a bearer token; pass --token at registration or set UKP_ENDPOINT_${binding.name.toUpperCase().replace(/-/g, "_")}_TOKEN`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const execution = await remoteRg(binding, transport, token, {
|
|
303
|
+
query: parsed.request.query,
|
|
304
|
+
limit: parsed.request.limit,
|
|
305
|
+
...(parsed.options.glob !== undefined ? { glob: parsed.options.glob } : {}),
|
|
306
|
+
...(parsed.options.type !== undefined ? { type: parsed.options.type } : {}),
|
|
307
|
+
...(parsed.options.ignoreCase === true ? { ignoreCase: true } : {}),
|
|
308
|
+
...(parsed.options.count === true ? { count: true } : {}),
|
|
309
|
+
passthrough: parsed.options.passthrough,
|
|
310
|
+
});
|
|
311
|
+
remoteOutcomes.set(binding.name, execution.outcome);
|
|
312
|
+
warnings.push(...execution.warnings);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
315
|
+
warnings.push(`endpoint '${binding.name}' rg failed: ${message}`);
|
|
316
|
+
remoteOutcomes.set(binding.name, {
|
|
317
|
+
name: binding.name,
|
|
318
|
+
provider: EXTERNAL_PROVIDER,
|
|
319
|
+
status: "failed",
|
|
320
|
+
message,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
pool.close();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const byName = new Map((localResult?.endpoints ?? []).map((outcome) => [outcome.name, outcome]));
|
|
329
|
+
const endpoints: RgEndpointOutcome[] = scope.bindings.map(
|
|
330
|
+
(binding) => byName.get(binding.name) ?? remoteOutcomes.get(binding.name)!,
|
|
331
|
+
);
|
|
332
|
+
const statuses = endpoints.map((outcome) => outcome.status);
|
|
333
|
+
const aggregate: RgAggregateStatus = statuses.includes("cancelled") || statuses.includes("interrupted")
|
|
334
|
+
? "cancelled"
|
|
335
|
+
: statuses.includes("failed")
|
|
336
|
+
? "failed"
|
|
337
|
+
: statuses.some((status) => status === "succeeded" || status === "no_matches")
|
|
338
|
+
? "succeeded"
|
|
339
|
+
: "no-success";
|
|
340
|
+
return renderRgCommandResult(parsed, {
|
|
341
|
+
query: parsed.request.query,
|
|
342
|
+
limit: parsed.request.limit,
|
|
343
|
+
endpoints,
|
|
344
|
+
warnings,
|
|
345
|
+
aggregate,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function renderRgHelp(): string {
|
|
350
|
+
return renderKitHelp(RG_SPEC);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function renderRgUsageError(message: string): string {
|
|
354
|
+
return renderKitUsageError(RG_SPEC, message);
|
|
355
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runSearch,
|
|
3
|
+
projectSearchEnvelope,
|
|
4
|
+
renderSearchHuman,
|
|
5
|
+
renderSearchJson,
|
|
6
|
+
SearchPlanningError,
|
|
7
|
+
type ParsedSearch,
|
|
8
|
+
type SearchAggregateStatus,
|
|
9
|
+
type SearchContext,
|
|
10
|
+
type SearchEndpointOutcome,
|
|
11
|
+
type SearchResult,
|
|
12
|
+
} from "../capabilities/search.ts";
|
|
13
|
+
import {
|
|
14
|
+
createTransportPool,
|
|
15
|
+
fetchDiscoveryDocument,
|
|
16
|
+
remoteSearch,
|
|
17
|
+
resolveRemoteToken,
|
|
18
|
+
} from "../capabilities/remote-client.ts";
|
|
19
|
+
import { isRemoteBinding, readRegistry } from "../registry.ts";
|
|
20
|
+
import { resolveScope, ScopeError } from "../scope.ts";
|
|
21
|
+
import { ManifestError } from "../config/manifest.ts";
|
|
22
|
+
import { KitUsageError, parseKitArgs, renderKitHelp, renderKitUsageError, type UkpCommandSpec } from "./kit.ts";
|
|
23
|
+
import { HelpRequestError, isHelpRequest } from "./flags.ts";
|
|
24
|
+
|
|
25
|
+
/** CLI-owned command result shape (ADR 0021: exit codes and channel text
|
|
26
|
+
* belong to the surface adapter, not the capability). */
|
|
27
|
+
export interface SearchCommandResult {
|
|
28
|
+
exitCode: number;
|
|
29
|
+
stdout: string;
|
|
30
|
+
stderr: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Aggregate classification → exit code (ADR 0021: the D-036 aggregation
|
|
34
|
+
* rule lives here, driven by the capability's structured classification —
|
|
35
|
+
* skip never changes the success state; a provider failure fails the run;
|
|
36
|
+
* an interrupt is 130). */
|
|
37
|
+
const SEARCH_EXIT_BY_AGGREGATE: Record<SearchAggregateStatus, number> = {
|
|
38
|
+
"succeeded": 0,
|
|
39
|
+
"provider-failure": 1,
|
|
40
|
+
"no-success": 1,
|
|
41
|
+
"cancelled": 130,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Single-source command spec (ADR 0024). Options-only form: `-c`/`-g` are
|
|
45
|
+
* declared inline rather than via `scope` so the rendered option order and
|
|
46
|
+
* the error precedence (limit validation before duplicate warnings, excess
|
|
47
|
+
* check before the conflict) stay byte-identical with the pre-kit command;
|
|
48
|
+
* adopting the generated scope family waits for an adjudicated
|
|
49
|
+
* precedence-unification pass. */
|
|
50
|
+
export const SEARCH_SPEC: UkpCommandSpec = {
|
|
51
|
+
name: "search",
|
|
52
|
+
summary: "search a Service's indexed content (provider-backed; use 'ukp rg' to grep raw files)",
|
|
53
|
+
group: "endpoint",
|
|
54
|
+
description: "Run atomic lexical search against the selected Service endpoints.",
|
|
55
|
+
usage: "<query> [--limit <1-1000>] [--endpoint <name> ... | -g] [--recursive] [--json]",
|
|
56
|
+
arguments: [{ name: "query", help: "one non-empty search query; quote multi-word queries" }],
|
|
57
|
+
options: [
|
|
58
|
+
{ flags: "--limit <1-1000>", help: "maximum results requested from each endpoint (default: 20)" },
|
|
59
|
+
{ flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
|
|
60
|
+
{ flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
|
|
61
|
+
{ flags: "--recursive", help: "include direct authority and context dependencies" },
|
|
62
|
+
{ flags: "--json", help: "write provider-native results to artifacts and print an envelope" },
|
|
63
|
+
],
|
|
64
|
+
helpSuffix: [
|
|
65
|
+
"",
|
|
66
|
+
"Result scope:",
|
|
67
|
+
" With no --endpoint or -g, the workspace default scope applies: the",
|
|
68
|
+
" Client Config's default endpoints ('ukp inspect' shows the resolved",
|
|
69
|
+
" scope); with no Client Config, every registered endpoint. -g cannot",
|
|
70
|
+
" be combined with --endpoint.",
|
|
71
|
+
" --endpoint <name> requests that Service's search capability. The result",
|
|
72
|
+
" range is decided by the Service's provider configuration and may include",
|
|
73
|
+
" shared collections; results are not guaranteed to be the endpoint's own",
|
|
74
|
+
" content. '== <name> ==' reports which Service was asked, not content",
|
|
75
|
+
" ownership.",
|
|
76
|
+
" --recursive keeps selected endpoints as depth-0 seeds and adds their",
|
|
77
|
+
" registered authority/context dependencies at depth 1.",
|
|
78
|
+
" Human results carry a copyable 'read:'/'uri:' handoff line that",
|
|
79
|
+
" 'ukp read' accepts directly.",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n"),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
interface SearchCommandOptions extends Record<string, unknown> {
|
|
85
|
+
limit?: string;
|
|
86
|
+
endpoint?: string[];
|
|
87
|
+
g?: boolean;
|
|
88
|
+
recursive?: boolean;
|
|
89
|
+
json?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseSearchArgs(args: readonly string[]): ParsedSearch {
|
|
93
|
+
let limit = 20;
|
|
94
|
+
const explicit: string[] = [];
|
|
95
|
+
const warnings: string[] = [];
|
|
96
|
+
const parsed = parseKitArgs<SearchCommandOptions>(SEARCH_SPEC, args);
|
|
97
|
+
|
|
98
|
+
// Command-side semantic checks, in the pre-kit order (limit validation
|
|
99
|
+
// directly after the generated singleton checks, then duplicates, excess,
|
|
100
|
+
// emptiness, conflict).
|
|
101
|
+
if (parsed.options.limit !== undefined) {
|
|
102
|
+
if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
|
|
103
|
+
limit = Number(parsed.options.limit);
|
|
104
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
|
|
105
|
+
throw new KitUsageError("--limit must be between 1 and 1000");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const endpoint of parsed.options.endpoint ?? []) {
|
|
110
|
+
if (explicit.includes(endpoint)) warnings.push(`duplicate endpoint '${endpoint}' ignored`);
|
|
111
|
+
else explicit.push(endpoint);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [query, unexpected] = parsed.positionals;
|
|
115
|
+
if (unexpected !== undefined) {
|
|
116
|
+
throw new KitUsageError(
|
|
117
|
+
`unexpected argument '${unexpected}'; search accepts exactly one query. `
|
|
118
|
+
+ "Use '--endpoint <name>' to select an endpoint; '-g' takes no value.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (query === undefined || query.length === 0) throw new KitUsageError("search query must be non-empty");
|
|
122
|
+
if (parsed.options.g && explicit.length > 0) {
|
|
123
|
+
throw new KitUsageError("--endpoint and -g cannot be used together");
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
request: { query, limit },
|
|
127
|
+
options: {
|
|
128
|
+
explicitEndpoints: explicit.length > 0 ? explicit : undefined,
|
|
129
|
+
global: parsed.options.g ?? false,
|
|
130
|
+
recursive: parsed.options.recursive ?? false,
|
|
131
|
+
json: parsed.options.json ?? false,
|
|
132
|
+
},
|
|
133
|
+
warnings,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Remote-in-scope detection (sync): when the selected scope contains remote
|
|
138
|
+
* bindings, execution switches to the async mixed driver — local endpoints
|
|
139
|
+
* keep the exact sync runSearch path (zero contract change), remote
|
|
140
|
+
* endpoints run over the wire. The conditional-async seam is the ukp_remote
|
|
141
|
+
* W2 migration boundary; full async-native execution is a later refactor. */
|
|
142
|
+
function selectedRemoteBindings(parsed: ParsedSearch, context: SearchContext) {
|
|
143
|
+
const registry = readRegistry(context.registryPath);
|
|
144
|
+
const scope = resolveScope({
|
|
145
|
+
currentDirectory: context.currentDirectory,
|
|
146
|
+
registry,
|
|
147
|
+
explicitEndpoints: parsed.options.explicitEndpoints,
|
|
148
|
+
global: parsed.options.global,
|
|
149
|
+
});
|
|
150
|
+
return { registry, scope, remotes: scope.bindings.filter(isRemoteBinding) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function executeSearchCommand(
|
|
154
|
+
args: readonly string[],
|
|
155
|
+
context: SearchContext,
|
|
156
|
+
): SearchCommandResult | Promise<SearchCommandResult> {
|
|
157
|
+
if (isHelpRequest(args)) {
|
|
158
|
+
return { exitCode: 0, stdout: renderSearchHelp(), stderr: "" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const parsed = parseSearchArgs(args);
|
|
163
|
+
const { scope, remotes } = selectedRemoteBindings(parsed, context);
|
|
164
|
+
if (remotes.length === 0) {
|
|
165
|
+
return executeHumanSearch(parsed, context);
|
|
166
|
+
}
|
|
167
|
+
// The mixed driver starts with a SYNC local runSearch whose typed errors
|
|
168
|
+
// would otherwise escape the try/catch as a promise rejection (W2 seam
|
|
169
|
+
// flaw, caught by the W6 review) — the same catch renders both paths.
|
|
170
|
+
return executeMixedSearch(parsed, context, scope, remotes).catch(renderSearchCommandError);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return renderSearchCommandError(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderSearchCommandError(error: unknown): SearchCommandResult {
|
|
177
|
+
if (error instanceof HelpRequestError) {
|
|
178
|
+
return { exitCode: 0, stdout: renderSearchHelp(), stderr: "" };
|
|
179
|
+
}
|
|
180
|
+
if (error instanceof KitUsageError) {
|
|
181
|
+
return { exitCode: 2, stdout: "", stderr: renderSearchUsageError(error.message) };
|
|
182
|
+
}
|
|
183
|
+
if (error instanceof ScopeError) {
|
|
184
|
+
return { exitCode: 1, stdout: "", stderr: `ukp search: ${error.message}\n` };
|
|
185
|
+
}
|
|
186
|
+
if (error instanceof SearchPlanningError) {
|
|
187
|
+
return { exitCode: 1, stdout: "", stderr: `ukp search: ${error.message}\n` };
|
|
188
|
+
}
|
|
189
|
+
if (error instanceof ManifestError) {
|
|
190
|
+
return { exitCode: 1, stdout: "", stderr: `ukp search: ${error.message}\n` };
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Mixed local+remote execution: locals via the unchanged sync runSearch,
|
|
196
|
+
* remotes via discovery-check + POST /v1/search, merged in scope order under
|
|
197
|
+
* the D-036 aggregation. */
|
|
198
|
+
async function executeMixedSearch(
|
|
199
|
+
parsed: ParsedSearch,
|
|
200
|
+
context: SearchContext,
|
|
201
|
+
scope: ReturnType<typeof resolveScope>,
|
|
202
|
+
remotes: ReturnType<typeof selectedRemoteBindings>["remotes"],
|
|
203
|
+
): Promise<SearchCommandResult> {
|
|
204
|
+
const remoteNames = new Set(remotes.map((binding) => binding.name));
|
|
205
|
+
const localNames = scope.bindings.filter((binding) => !remoteNames.has(binding.name)).map((b) => b.name);
|
|
206
|
+
const baseWarnings: string[] = [...parsed.warnings, ...scope.warnings];
|
|
207
|
+
|
|
208
|
+
let localResult: SearchResult | undefined;
|
|
209
|
+
if (localNames.length > 0) {
|
|
210
|
+
localResult = runSearch(
|
|
211
|
+
{
|
|
212
|
+
...parsed,
|
|
213
|
+
options: { ...parsed.options, explicitEndpoints: localNames, global: false },
|
|
214
|
+
warnings: [...baseWarnings],
|
|
215
|
+
},
|
|
216
|
+
context,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
// Local run warnings (provider failures, duplicate-endpoint notes) carry
|
|
220
|
+
// into the merged view; remote-phase warnings append after them.
|
|
221
|
+
const warnings: string[] = localResult !== undefined ? [...localResult.warnings] : [...baseWarnings];
|
|
222
|
+
|
|
223
|
+
const remoteOutcomes = new Map<string, SearchEndpointOutcome>();
|
|
224
|
+
// One transport pool for the whole invocation (W7 / O-5): same-origin door
|
|
225
|
+
// bindings share a single ssh tunnel instead of one per row.
|
|
226
|
+
const pool = createTransportPool({ registryPath: context.registryPath });
|
|
227
|
+
try {
|
|
228
|
+
for (const binding of remotes) {
|
|
229
|
+
const token = resolveRemoteToken(binding);
|
|
230
|
+
try {
|
|
231
|
+
const transport = await pool.acquire(binding);
|
|
232
|
+
const discovery = await fetchDiscoveryDocument(binding, transport, token);
|
|
233
|
+
warnings.push(...discovery.warnings);
|
|
234
|
+
if (discovery.bearerRequired && token === undefined) {
|
|
235
|
+
warnings.push(
|
|
236
|
+
`endpoint '${binding.name}' requires a bearer token; pass --token at registration or set UKP_ENDPOINT_${binding.name.toUpperCase().replace(/-/g, "_")}_TOKEN`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (!("search" in discovery.doc.capabilities)) {
|
|
240
|
+
const message = `endpoint '${binding.name}' declares no search capability`;
|
|
241
|
+
// Local planned skips surface their reason as a warning (D-036 render
|
|
242
|
+
// contract); the remote skip joins them so stderr stays informative.
|
|
243
|
+
warnings.push(message);
|
|
244
|
+
remoteOutcomes.set(binding.name, {
|
|
245
|
+
name: binding.name,
|
|
246
|
+
provider: null,
|
|
247
|
+
status: "skipped",
|
|
248
|
+
message,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const execution = await remoteSearch(binding, transport, token, parsed.request.query, parsed.request.limit);
|
|
253
|
+
remoteOutcomes.set(binding.name, {
|
|
254
|
+
...execution.outcome,
|
|
255
|
+
...(execution.results.length > 0 ? { providerOutput: JSON.stringify(execution.results) } : {}),
|
|
256
|
+
...(execution.references !== undefined
|
|
257
|
+
? { remoteUris: execution.references.map((entry) => entry.ukp_uri) }
|
|
258
|
+
: {}),
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
262
|
+
// Local failures surface via warnings (D-036 render contract); remote
|
|
263
|
+
// transport failures join them so stderr stays the failure channel.
|
|
264
|
+
warnings.push(`endpoint '${binding.name}' provider failed: ${message}`);
|
|
265
|
+
remoteOutcomes.set(binding.name, {
|
|
266
|
+
name: binding.name,
|
|
267
|
+
provider: null,
|
|
268
|
+
status: "failed",
|
|
269
|
+
message,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
pool.close();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const byName = new Map((localResult?.endpoints ?? []).map((outcome) => [outcome.name, outcome]));
|
|
278
|
+
const endpoints: SearchEndpointOutcome[] = scope.bindings.map(
|
|
279
|
+
(binding) => byName.get(binding.name) ?? remoteOutcomes.get(binding.name)!,
|
|
280
|
+
);
|
|
281
|
+
const statuses = endpoints.map((outcome) => outcome.status);
|
|
282
|
+
const aggregate: SearchAggregateStatus = statuses.includes("cancelled")
|
|
283
|
+
? "cancelled"
|
|
284
|
+
: statuses.includes("failed")
|
|
285
|
+
? "provider-failure"
|
|
286
|
+
: statuses.includes("succeeded")
|
|
287
|
+
? "succeeded"
|
|
288
|
+
: "no-success";
|
|
289
|
+
const merged: SearchResult = {
|
|
290
|
+
query: parsed.request.query,
|
|
291
|
+
limit: parsed.request.limit,
|
|
292
|
+
...(localResult?.runId !== undefined ? { runId: localResult.runId } : {}),
|
|
293
|
+
endpoints,
|
|
294
|
+
warnings,
|
|
295
|
+
aggregate,
|
|
296
|
+
};
|
|
297
|
+
return renderSearchCommandResult(parsed.options.json, merged);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function renderSearchCommandResult(json: boolean, result: SearchResult): SearchCommandResult {
|
|
301
|
+
const exitCode = SEARCH_EXIT_BY_AGGREGATE[result.aggregate];
|
|
302
|
+
if (json) {
|
|
303
|
+
return { exitCode, stdout: renderSearchJson(projectSearchEnvelope(result)), stderr: "" };
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
exitCode,
|
|
307
|
+
stdout: renderSearchHuman(result),
|
|
308
|
+
stderr: result.warnings.length > 0 ? `${result.warnings.join("\n")}\n` : "",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** CLI composition of the structured outcome (ADR 0021): the shared renders
|
|
313
|
+
* plus the aggregate → exit-code mapping. Kept as the test entry so suites
|
|
314
|
+
* exercise the exact adapter path the `ukp search` bin takes. */
|
|
315
|
+
export function executeHumanSearch(parsed: ParsedSearch, context: SearchContext): SearchCommandResult {
|
|
316
|
+
return renderSearchCommandResult(parsed.options.json, runSearch(parsed, context));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function renderSearchHelp(): string {
|
|
320
|
+
return renderKitHelp(SEARCH_SPEC);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function renderSearchUsageError(message: string): string {
|
|
324
|
+
return renderKitUsageError(SEARCH_SPEC, message);
|
|
325
|
+
}
|