@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,570 @@
|
|
|
1
|
+
import { ENDPOINT_NAME, loadManifest } from "../config/manifest.ts";
|
|
2
|
+
import { FILE_NATIVE_CAPABILITIES, isFileNativeCapability } from "../config/file-native.ts";
|
|
3
|
+
import { diagnoseService, type ProviderResolver } from "./diagnose.ts";
|
|
4
|
+
import {
|
|
5
|
+
assertRemoteUrlAllowed,
|
|
6
|
+
isRemoteBinding,
|
|
7
|
+
parseRemoteUrl,
|
|
8
|
+
readRegistry,
|
|
9
|
+
registerAt,
|
|
10
|
+
registerRemoteAt,
|
|
11
|
+
unregisterAt,
|
|
12
|
+
type RegistryBinding,
|
|
13
|
+
} from "../registry.ts";
|
|
14
|
+
import {
|
|
15
|
+
createTransportPool,
|
|
16
|
+
fetchDiscoveryDocument,
|
|
17
|
+
fetchDoorDocument,
|
|
18
|
+
fetchRegistrationDocument,
|
|
19
|
+
probeRemoteTls,
|
|
20
|
+
remoteTokenFor,
|
|
21
|
+
resolveRemoteToken,
|
|
22
|
+
type RemoteTransportPool,
|
|
23
|
+
} from "../capabilities/remote-client.ts";
|
|
24
|
+
import {
|
|
25
|
+
KitUsageError,
|
|
26
|
+
parseKitArgs,
|
|
27
|
+
renderKitHelp,
|
|
28
|
+
renderKitUsageError,
|
|
29
|
+
type UkpCommandSpec,
|
|
30
|
+
} from "./kit.ts";
|
|
31
|
+
import { HelpRequestError, isHelpRequest } from "./flags.ts";
|
|
32
|
+
|
|
33
|
+
export interface InventoryCommandContext {
|
|
34
|
+
currentDirectory: string;
|
|
35
|
+
registryPath: string;
|
|
36
|
+
resolveProvider?: ProviderResolver;
|
|
37
|
+
/** Test injection point for the ssh binary behind ssh:// transports. */
|
|
38
|
+
sshCommand?: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InventoryCommandResult {
|
|
42
|
+
exitCode: number;
|
|
43
|
+
stdout: string;
|
|
44
|
+
stderr: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Single-source command specs (ADR 0024): summaries feed the root help via
|
|
48
|
+
* cli.ts; usage feeds the help header and the usage-error line; excess
|
|
49
|
+
* positionals are rejected by commander (strictArguments). */
|
|
50
|
+
export const REGISTER_SPEC: UkpCommandSpec = {
|
|
51
|
+
name: "register",
|
|
52
|
+
summary: "register a Service endpoint",
|
|
53
|
+
group: "registry",
|
|
54
|
+
description:
|
|
55
|
+
"Register the current Service folder, a remote ukp-serve endpoint via --url, or — when that url is a host door — every endpoint behind it in one gesture.",
|
|
56
|
+
usage: "[options]",
|
|
57
|
+
strictArguments: true,
|
|
58
|
+
options: [
|
|
59
|
+
{ flags: "--url <url>", help: "register a remote ukp-serve endpoint (https, ssh://host[:port][/endpoint], or loopback http); a host door url (its document declares scope:\"host\") imports every endpoint behind the door — binding urls gain the endpoint-name path (ssh://ali/notes)" },
|
|
60
|
+
{ flags: "--select <names>", help: "with a host door url: import only the named endpoints (comma-separated); a name not on the door is a usage error" },
|
|
61
|
+
{ flags: "--token <token>", help: "store the bearer token in the binding (plaintext, 0600 registry file); with a host door it is copied into every imported binding; UKP_ENDPOINT_<NAME>_TOKEN overrides it at call time" },
|
|
62
|
+
],
|
|
63
|
+
helpSuffix: [
|
|
64
|
+
"",
|
|
65
|
+
"Recovery:",
|
|
66
|
+
" Registering a name already bound to a different location is rejected.",
|
|
67
|
+
" Unregister the old binding first, then register from the new Service folder:",
|
|
68
|
+
" ukp unregister --endpoint <name>",
|
|
69
|
+
"",
|
|
70
|
+
"Binding:",
|
|
71
|
+
" The Host Registry binds the Service Manifest's effective name to this",
|
|
72
|
+
" folder's location; 'ukp list' shows the resulting bindings.",
|
|
73
|
+
" With --url, the name and instance identity come from the remote",
|
|
74
|
+
" discovery document (/.well-known/ukp.json) and are pinned TOFU-style;",
|
|
75
|
+
" bearer-token services read UKP_ENDPOINT_<NAME>_TOKEN at call time.",
|
|
76
|
+
"",
|
|
77
|
+
"Host doors (ADR-REM-004):",
|
|
78
|
+
" 'ukp register --url ssh://ali' fetches the door's discovery document",
|
|
79
|
+
" and imports every endpoint behind it (a door url may also name one",
|
|
80
|
+
" endpoint: ssh://ali/notes). Import is explicit and idempotent:",
|
|
81
|
+
" re-running refreshes TOFU pins and tokens. Name conflicts are skipped",
|
|
82
|
+
" with a visible reason (never silently renamed); --select narrows the",
|
|
83
|
+
" import; door growth shows in 'ukp list' as drift notes until imported.",
|
|
84
|
+
"",
|
|
85
|
+
].join("\n"),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const LIST_SPEC: UkpCommandSpec = {
|
|
89
|
+
name: "list",
|
|
90
|
+
summary: "list registered endpoint bindings",
|
|
91
|
+
group: "registry",
|
|
92
|
+
description: "List registered endpoint bindings and capabilities.",
|
|
93
|
+
usage: "[options]",
|
|
94
|
+
strictArguments: true,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const UNREGISTER_SPEC: UkpCommandSpec = {
|
|
98
|
+
name: "unregister",
|
|
99
|
+
summary: "remove a registered endpoint binding (files on disk are untouched)",
|
|
100
|
+
group: "registry",
|
|
101
|
+
description: "Remove a registered endpoint.",
|
|
102
|
+
usage: "--endpoint <name>",
|
|
103
|
+
arguments: [{ name: "name", help: "legacy alias for --endpoint <name>; prefer the flag form" }],
|
|
104
|
+
options: [{ flags: "-c, --endpoint <name>", help: "select the endpoint binding to remove" }],
|
|
105
|
+
strictArguments: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
interface UnregisterCommandOptions extends Record<string, unknown> {
|
|
109
|
+
endpoint?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Derived file-native capabilities (read/nav today) exist on every registered
|
|
113
|
+
// endpoint without a declaration (ADR 0016 rule 2); they are stated once in
|
|
114
|
+
// the header instead of being repeated on every row. Everything else a
|
|
115
|
+
// Service declares (search, update, propose, ...) is per-endpoint news and
|
|
116
|
+
// gets its own column.
|
|
117
|
+
const DEFAULT_CAPABILITIES = (Object.keys(FILE_NATIVE_CAPABILITIES) as Array<keyof typeof FILE_NATIVE_CAPABILITIES>)
|
|
118
|
+
.filter((name) => FILE_NATIVE_CAPABILITIES[name].derived)
|
|
119
|
+
.sort();
|
|
120
|
+
|
|
121
|
+
function renderLocalListRow(endpoint: { name: string; path: string }): { line: string; warning?: string } {
|
|
122
|
+
let service;
|
|
123
|
+
try {
|
|
124
|
+
service = loadManifest(endpoint.path);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// One-line headline only: an inventory warning must stay scannable even
|
|
127
|
+
// when the underlying ManifestError carries a full zod schema dump.
|
|
128
|
+
const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
129
|
+
return {
|
|
130
|
+
line: `${endpoint.name}\t${endpoint.path}\t(unavailable)`,
|
|
131
|
+
warning: `endpoint '${endpoint.name}' capabilities unavailable: ${headline}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const extras = Object.keys(service.manifest.capabilities)
|
|
135
|
+
.filter((name) => !(isFileNativeCapability(name) && FILE_NATIVE_CAPABILITIES[name].derived))
|
|
136
|
+
.sort();
|
|
137
|
+
return { line: `${endpoint.name}\t${endpoint.path}\t${extras.length > 0 ? extras.join(",") : "-"}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function executeRegisterCommand(
|
|
141
|
+
args: readonly string[],
|
|
142
|
+
context: InventoryCommandContext,
|
|
143
|
+
): InventoryCommandResult | Promise<InventoryCommandResult> {
|
|
144
|
+
if (isHelpRequest(args)) {
|
|
145
|
+
return { exitCode: 0, stdout: renderKitHelp(REGISTER_SPEC), stderr: "" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const parsed = parseKitArgs<{ url?: string; token?: string; select?: string }>(REGISTER_SPEC, args);
|
|
150
|
+
if (parsed.options.url !== undefined) {
|
|
151
|
+
return executeRegisterRemote(parsed.options.url, parsed.options.token, parsed.options.select, context);
|
|
152
|
+
}
|
|
153
|
+
if (parsed.options.select !== undefined) {
|
|
154
|
+
throw new KitUsageError("--select requires --url <door url>");
|
|
155
|
+
}
|
|
156
|
+
const report = diagnoseService(context.currentDirectory, context.resolveProvider);
|
|
157
|
+
registerAt(context.registryPath, report.service.effectiveName, report.service.folder);
|
|
158
|
+
const lines = [
|
|
159
|
+
`registered: ${report.service.effectiveName}`,
|
|
160
|
+
...(report.service.manifest.description ? [`description: ${report.service.manifest.description}`] : []),
|
|
161
|
+
`location: ${report.service.folder}`,
|
|
162
|
+
];
|
|
163
|
+
return {
|
|
164
|
+
exitCode: 0,
|
|
165
|
+
stdout: lines.join("\n"),
|
|
166
|
+
stderr: "",
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof HelpRequestError) {
|
|
170
|
+
return { exitCode: 0, stdout: renderKitHelp(REGISTER_SPEC), stderr: "" };
|
|
171
|
+
}
|
|
172
|
+
if (error instanceof KitUsageError) {
|
|
173
|
+
return { exitCode: 2, stdout: "", stderr: renderKitUsageError(REGISTER_SPEC, error.message) };
|
|
174
|
+
}
|
|
175
|
+
return { exitCode: 1, stdout: "", stderr: `ukp register: ${error instanceof Error ? error.message : String(error)}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Door-form binding url (O-3): canonical door origin + `/` + endpoint name —
|
|
180
|
+
* the door lives in the url, indistinguishable from a direct registration. */
|
|
181
|
+
function doorEndpointUrl(origin: string, name: string): string {
|
|
182
|
+
return `${origin}/${name}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Declared-capability column for import reports: same convention as list
|
|
186
|
+
* rows — derived file-native capabilities are ambient, everything else is
|
|
187
|
+
* per-endpoint news; `-` when the endpoint declares nothing extra. */
|
|
188
|
+
function declaredCapabilitySummary(capabilities: Record<string, unknown>): string {
|
|
189
|
+
const extras = Object.entries(capabilities as Record<string, { derived?: boolean }>)
|
|
190
|
+
.filter(([, capability]) => capability.derived !== true)
|
|
191
|
+
.map(([name]) => name)
|
|
192
|
+
.sort();
|
|
193
|
+
return extras.length > 0 ? extras.join(",") : "-";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Import the door roster into the registry (ADR-REM-004 / O-4): default is
|
|
197
|
+
* every endpoint, `--select` narrows, name/url conflicts are skipped with a
|
|
198
|
+
* visible reason (RQ-14: never silently renamed), and same name+url is the
|
|
199
|
+
* idempotent refresh (TOFU pin + token, the W4 semantics in batch). ≥1
|
|
200
|
+
* import/refresh exits 0; all-skipped exits 1. */
|
|
201
|
+
function importDoorEndpoints(
|
|
202
|
+
origin: string,
|
|
203
|
+
door: { endpoints: ReadonlyArray<{ name: string; instance_uid: string; capabilities: Record<string, unknown> }> },
|
|
204
|
+
targets: ReadonlyArray<{ name: string; instance_uid: string; capabilities: Record<string, unknown> }>,
|
|
205
|
+
credentials: { token?: string; tls_cert?: string; tls_pin?: string },
|
|
206
|
+
context: InventoryCommandContext,
|
|
207
|
+
): InventoryCommandResult {
|
|
208
|
+
const existing = readRegistry(context.registryPath);
|
|
209
|
+
const lines = [`door ${origin}: ${door.endpoints.length} endpoint(s)`];
|
|
210
|
+
let landed = 0;
|
|
211
|
+
for (const target of targets) {
|
|
212
|
+
const url = doorEndpointUrl(origin, target.name);
|
|
213
|
+
const sameName = existing.find((endpoint) => endpoint.name === target.name);
|
|
214
|
+
if (sameName !== undefined && !(sameName.kind === "remote" && sameName.url === url)) {
|
|
215
|
+
lines.push(`skipped: ${target.name} (already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path})`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const sameUrl = existing.find(
|
|
219
|
+
(endpoint) => endpoint.kind === "remote" && endpoint.url === url && endpoint.name !== target.name,
|
|
220
|
+
);
|
|
221
|
+
if (sameUrl !== undefined) {
|
|
222
|
+
lines.push(`skipped: ${target.name} (url ${url} already bound to '${sameUrl.name}')`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
registerRemoteAt(context.registryPath, {
|
|
226
|
+
name: target.name,
|
|
227
|
+
url,
|
|
228
|
+
instance_uid: target.instance_uid,
|
|
229
|
+
...(credentials.token !== undefined ? { token: credentials.token } : {}),
|
|
230
|
+
...(credentials.tls_cert !== undefined && credentials.tls_pin !== undefined
|
|
231
|
+
? { tls_cert: credentials.tls_cert, tls_pin: credentials.tls_pin }
|
|
232
|
+
: {}),
|
|
233
|
+
});
|
|
234
|
+
landed += 1;
|
|
235
|
+
lines.push(
|
|
236
|
+
sameName !== undefined
|
|
237
|
+
? `refreshed: ${target.name} (TOFU pin/credentials refreshed)`
|
|
238
|
+
: `imported: ${target.name} (${declaredCapabilitySummary(target.capabilities)})`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
exitCode: landed > 0 ? 0 : 1,
|
|
243
|
+
stdout: lines.join("\n"),
|
|
244
|
+
stderr: "",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Remote registration (D-077/D-078 / spec endpoint-registration; host doors
|
|
249
|
+
* since W7 / ADR-REM-004): the well-known document at the url's origin
|
|
250
|
+
* self-describes — an endpoint document (no `scope`) registers one endpoint
|
|
251
|
+
* (today's behavior, name from the document per RQ-14, TOFU pin, --token in
|
|
252
|
+
* the binding); a door document (`scope:"host"`) runs the import flow above.
|
|
253
|
+
* ssh:// urls tunnel transparently; https urls probe TLS first (W5' / D-079):
|
|
254
|
+
* a public-CA chain validates normally, a self-signed certificate is
|
|
255
|
+
* TOFU-pinned into the binding(s) and verified on every later call. */
|
|
256
|
+
async function executeRegisterRemote(
|
|
257
|
+
url: string,
|
|
258
|
+
token: string | undefined,
|
|
259
|
+
select: string | undefined,
|
|
260
|
+
context: InventoryCommandContext,
|
|
261
|
+
): Promise<InventoryCommandResult> {
|
|
262
|
+
const selected = select === undefined
|
|
263
|
+
? undefined
|
|
264
|
+
: select.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
265
|
+
if (selected !== undefined && selected.length === 0) {
|
|
266
|
+
throw new KitUsageError("--select requires at least one endpoint name");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let pool: RemoteTransportPool | undefined;
|
|
270
|
+
try {
|
|
271
|
+
assertRemoteUrlAllowed(url);
|
|
272
|
+
const parts = parseRemoteUrl(url);
|
|
273
|
+
if (parts === undefined) {
|
|
274
|
+
throw new Error(`remote endpoint url is not admissible: ${url}`);
|
|
275
|
+
}
|
|
276
|
+
const tlsProbe = await probeRemoteTls(parts.origin);
|
|
277
|
+
const pinSelfSigned = tlsProbe !== undefined && !tlsProbe.authorized;
|
|
278
|
+
pool = createTransportPool(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand });
|
|
279
|
+
const transport = await pool.acquire({ name: "(registering)", kind: "remote", url: parts.origin });
|
|
280
|
+
const wellKnown = await fetchRegistrationDocument(transport.base, {
|
|
281
|
+
...(token !== undefined ? { token } : {}),
|
|
282
|
+
...(pinSelfSigned && tlsProbe !== undefined ? { tlsAnchor: { ca: tlsProbe.certPem } } : {}),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (wellKnown.kind === "door") {
|
|
286
|
+
const roster = wellKnown.door.doc.endpoints;
|
|
287
|
+
for (const endpoint of roster) {
|
|
288
|
+
if (!ENDPOINT_NAME.test(endpoint.name) || endpoint.instance_uid.length === 0) {
|
|
289
|
+
throw new Error(`host door at ${parts.origin} declares an invalid endpoint '${endpoint.name}'`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const rosterNames = roster.map((endpoint) => endpoint.name).join(", ");
|
|
293
|
+
let targets;
|
|
294
|
+
if (parts.endpointName !== undefined) {
|
|
295
|
+
// A path segment addresses ONE endpoint through the door (H-rehearsal):
|
|
296
|
+
// membership is checked against the live roster, unknown names say so.
|
|
297
|
+
const found = roster.find((endpoint) => endpoint.name === parts.endpointName);
|
|
298
|
+
if (found === undefined) {
|
|
299
|
+
throw new Error(`no such endpoint '${parts.endpointName}' on door ${parts.origin} (available: ${rosterNames})`);
|
|
300
|
+
}
|
|
301
|
+
targets = [found];
|
|
302
|
+
} else if (selected !== undefined) {
|
|
303
|
+
const unknown = selected.filter((name) => !roster.some((endpoint) => endpoint.name === name));
|
|
304
|
+
if (unknown.length > 0) {
|
|
305
|
+
throw new KitUsageError(
|
|
306
|
+
`--select names not on door ${parts.origin}: ${unknown.join(", ")} (available: ${rosterNames})`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
targets = roster.filter((endpoint) => selected.includes(endpoint.name));
|
|
310
|
+
} else {
|
|
311
|
+
targets = [...roster];
|
|
312
|
+
}
|
|
313
|
+
const result = importDoorEndpoints(
|
|
314
|
+
parts.origin,
|
|
315
|
+
{ endpoints: roster },
|
|
316
|
+
targets,
|
|
317
|
+
{
|
|
318
|
+
...(token !== undefined ? { token } : {}),
|
|
319
|
+
...(pinSelfSigned && tlsProbe !== undefined
|
|
320
|
+
? { tls_cert: tlsProbe.certPem, tls_pin: tlsProbe.spkiPin }
|
|
321
|
+
: {}),
|
|
322
|
+
},
|
|
323
|
+
context,
|
|
324
|
+
);
|
|
325
|
+
const nameHint = wellKnown.door.bearerRequired && token === undefined
|
|
326
|
+
? [`note: this door requires a bearer token; pass --token or set UKP_ENDPOINT_<NAME>_TOKEN per endpoint`]
|
|
327
|
+
: [];
|
|
328
|
+
return {
|
|
329
|
+
...result,
|
|
330
|
+
stdout: [...result.stdout.split("\n"), ...nameHint].join("\n"),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Endpoint document at the origin: today's single registration.
|
|
335
|
+
if (parts.endpointName !== undefined) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`no such endpoint '${parts.endpointName}' on ${parts.origin}: it serves a single endpoint '${wellKnown.discovery.doc.name}' (not a host door)`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (selected !== undefined) {
|
|
341
|
+
throw new KitUsageError(`--select requires a host door url: ${parts.origin} serves a single endpoint`);
|
|
342
|
+
}
|
|
343
|
+
const name = wellKnown.discovery.doc.name;
|
|
344
|
+
if (!ENDPOINT_NAME.test(name)) {
|
|
345
|
+
throw new Error(`discovery document declares an invalid endpoint name '${name}'`);
|
|
346
|
+
}
|
|
347
|
+
if (wellKnown.discovery.doc.instance_uid === undefined || typeof wellKnown.discovery.doc.instance_uid !== "string") {
|
|
348
|
+
throw new Error("discovery document carries no instance_uid; the service must run a ukp-remote v1 server");
|
|
349
|
+
}
|
|
350
|
+
registerRemoteAt(context.registryPath, {
|
|
351
|
+
name,
|
|
352
|
+
url,
|
|
353
|
+
instance_uid: wellKnown.discovery.doc.instance_uid,
|
|
354
|
+
...(token !== undefined ? { token } : {}),
|
|
355
|
+
...(pinSelfSigned && tlsProbe !== undefined
|
|
356
|
+
? { tls_cert: tlsProbe.certPem, tls_pin: tlsProbe.spkiPin }
|
|
357
|
+
: {}),
|
|
358
|
+
});
|
|
359
|
+
const envToken = remoteTokenFor(name);
|
|
360
|
+
const lines = [
|
|
361
|
+
`registered (remote): ${name}`,
|
|
362
|
+
`url: ${url}`,
|
|
363
|
+
`instance_uid: ${wellKnown.discovery.doc.instance_uid}`,
|
|
364
|
+
...(pinSelfSigned && tlsProbe !== undefined
|
|
365
|
+
? [`tls: pinned ${tlsProbe.spkiPin} (self-signed; compare with the serve banner out-of-band on untrusted networks)`]
|
|
366
|
+
: []),
|
|
367
|
+
...(wellKnown.discovery.doc.description !== undefined ? [`description: ${wellKnown.discovery.doc.description}`] : []),
|
|
368
|
+
...(token !== undefined
|
|
369
|
+
? [`auth: token stored in registry binding (plaintext; env UKP_ENDPOINT_${name.toUpperCase().replace(/-/g, "_")}_TOKEN overrides)`]
|
|
370
|
+
: wellKnown.discovery.bearerRequired && envToken === undefined
|
|
371
|
+
? [`note: this endpoint requires a bearer token; pass --token or set UKP_ENDPOINT_${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]
|
|
372
|
+
: []),
|
|
373
|
+
];
|
|
374
|
+
return { exitCode: 0, stdout: lines.join("\n"), stderr: "" };
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error instanceof KitUsageError) {
|
|
377
|
+
return { exitCode: 2, stdout: "", stderr: renderKitUsageError(REGISTER_SPEC, error.message) };
|
|
378
|
+
}
|
|
379
|
+
return { exitCode: 1, stdout: "", stderr: `ukp register: ${error instanceof Error ? error.message : String(error)}` };
|
|
380
|
+
} finally {
|
|
381
|
+
pool?.close();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function executeListCommand(
|
|
386
|
+
args: readonly string[],
|
|
387
|
+
context: InventoryCommandContext,
|
|
388
|
+
): InventoryCommandResult | Promise<InventoryCommandResult> {
|
|
389
|
+
if (isHelpRequest(args)) {
|
|
390
|
+
return { exitCode: 0, stdout: renderKitHelp(LIST_SPEC), stderr: "" };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
parseKitArgs(LIST_SPEC, args);
|
|
395
|
+
const endpoints = readRegistry(context.registryPath);
|
|
396
|
+
if (endpoints.length === 0) {
|
|
397
|
+
return { exitCode: 0, stdout: "No endpoints registered.", stderr: "" };
|
|
398
|
+
}
|
|
399
|
+
const remotes = endpoints.filter(isRemoteBinding);
|
|
400
|
+
if (remotes.length === 0) {
|
|
401
|
+
return renderListOutput(endpoints.map((endpoint) => renderLocalListRow({ name: endpoint.name, path: endpoint.path! })));
|
|
402
|
+
}
|
|
403
|
+
// Remote rows fetch the discovery document (fresh per invocation); the
|
|
404
|
+
// same conditional-async seam as search/read keeps all-local listings
|
|
405
|
+
// byte-identical and synchronous. One transport pool serves the whole
|
|
406
|
+
// invocation (O-5): same-origin door bindings — and their drift check —
|
|
407
|
+
// share a single ssh tunnel.
|
|
408
|
+
return (async () => {
|
|
409
|
+
const pool = createTransportPool({
|
|
410
|
+
registryPath: context.registryPath,
|
|
411
|
+
...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
const rendered: Array<{ line: string; warning?: string }> = [];
|
|
415
|
+
for (const endpoint of endpoints) {
|
|
416
|
+
if (!isRemoteBinding(endpoint)) {
|
|
417
|
+
rendered.push(renderLocalListRow({ name: endpoint.name, path: endpoint.path! }));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
rendered.push(await renderRemoteListRow(endpoint, pool));
|
|
421
|
+
}
|
|
422
|
+
const drift = await collectDoorDriftNotes(endpoints, pool);
|
|
423
|
+
return renderListOutput(rendered, drift);
|
|
424
|
+
} finally {
|
|
425
|
+
pool.close();
|
|
426
|
+
}
|
|
427
|
+
})();
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error instanceof HelpRequestError) {
|
|
430
|
+
return { exitCode: 0, stdout: renderKitHelp(LIST_SPEC), stderr: "" };
|
|
431
|
+
}
|
|
432
|
+
if (error instanceof KitUsageError) {
|
|
433
|
+
return { exitCode: 2, stdout: "", stderr: renderKitUsageError(LIST_SPEC, error.message) };
|
|
434
|
+
}
|
|
435
|
+
return { exitCode: 1, stdout: "", stderr: `ukp list: ${error instanceof Error ? error.message : String(error)}` };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function renderListOutput(
|
|
440
|
+
rows: ReadonlyArray<{ line: string; warning?: string }>,
|
|
441
|
+
extraNotes: readonly string[] = [],
|
|
442
|
+
): InventoryCommandResult {
|
|
443
|
+
const warnings = [...rows.flatMap((row) => (row.warning !== undefined ? [row.warning] : [])), ...extraNotes];
|
|
444
|
+
const stdout = [
|
|
445
|
+
`capabilities on every endpoint: ${DEFAULT_CAPABILITIES.join(", ")} (derived file-native); additional declared capabilities per endpoint:`,
|
|
446
|
+
...rows.map((row) => row.line),
|
|
447
|
+
].join("\n");
|
|
448
|
+
return {
|
|
449
|
+
exitCode: 0,
|
|
450
|
+
stdout,
|
|
451
|
+
stderr: warnings.length > 0 ? `${warnings.join("\n")}\n` : "",
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Remote row: capabilities from the discovery document; unreachable
|
|
456
|
+
* endpoints degrade to `(unavailable)` + one stderr warning — the same
|
|
457
|
+
* inventory semantics as an unreadable local manifest. Door-form bindings
|
|
458
|
+
* (`ssh://ali/notes`) fetch their per-endpoint document through the shared
|
|
459
|
+
* pool's tunnel. */
|
|
460
|
+
async function renderRemoteListRow(
|
|
461
|
+
endpoint: RegistryBinding,
|
|
462
|
+
pool: RemoteTransportPool,
|
|
463
|
+
): Promise<{ line: string; warning?: string }> {
|
|
464
|
+
const url = endpoint.url!;
|
|
465
|
+
try {
|
|
466
|
+
const transport = await pool.acquire(endpoint);
|
|
467
|
+
const discovery = await fetchDiscoveryDocument(endpoint, transport, resolveRemoteToken(endpoint));
|
|
468
|
+
const extras = Object.entries(discovery.doc.capabilities)
|
|
469
|
+
.filter(([, capability]) => capability.derived !== true)
|
|
470
|
+
.map(([name]) => name)
|
|
471
|
+
.sort();
|
|
472
|
+
const warnings = discovery.warnings.length > 0 ? { warning: discovery.warnings.join("; ") } : {};
|
|
473
|
+
return { line: `${endpoint.name}\t${url}\t${extras.length > 0 ? extras.join(",") : "-"}`, ...warnings };
|
|
474
|
+
} catch (error) {
|
|
475
|
+
const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
476
|
+
return {
|
|
477
|
+
line: `${endpoint.name}\t${url}\t(unavailable)`,
|
|
478
|
+
warning: `endpoint '${endpoint.name}' capabilities unavailable: ${headline}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Door drift notes (ADR-REM-004 / O-4): group door-form bindings by their
|
|
484
|
+
* url origin, fetch each door's document ONCE per invocation (riding the
|
|
485
|
+
* pool's shared tunnel), and note the endpoints the door serves that the
|
|
486
|
+
* ledger has not imported. The view is dynamic, the ledger stays static —
|
|
487
|
+
* an unreachable door skips its check silently (rows already degrade to
|
|
488
|
+
* `(unavailable)` on their own) and exit code never moves. */
|
|
489
|
+
async function collectDoorDriftNotes(
|
|
490
|
+
endpoints: readonly RegistryBinding[],
|
|
491
|
+
pool: RemoteTransportPool,
|
|
492
|
+
): Promise<string[]> {
|
|
493
|
+
const origins = new Map<string, { members: RegistryBinding[]; imported: Set<string> }>();
|
|
494
|
+
for (const endpoint of endpoints) {
|
|
495
|
+
if (!isRemoteBinding(endpoint)) continue;
|
|
496
|
+
const parts = parseRemoteUrl(endpoint.url ?? "");
|
|
497
|
+
if (parts === undefined || parts.endpointName === undefined) continue;
|
|
498
|
+
let group = origins.get(parts.origin);
|
|
499
|
+
if (group === undefined) {
|
|
500
|
+
group = { members: [], imported: new Set<string>() };
|
|
501
|
+
origins.set(parts.origin, group);
|
|
502
|
+
}
|
|
503
|
+
group.members.push(endpoint);
|
|
504
|
+
group.imported.add(endpoint.name);
|
|
505
|
+
}
|
|
506
|
+
const notes: string[] = [];
|
|
507
|
+
for (const [origin, group] of [...origins.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
|
|
508
|
+
// Anchor TLS on a member binding (https doors: same certificate serves
|
|
509
|
+
// the door and its endpoints); the door document itself is public, so no
|
|
510
|
+
// credential is spent on the check.
|
|
511
|
+
const anchor = group.members[0]!;
|
|
512
|
+
try {
|
|
513
|
+
const transport = await pool.acquire({
|
|
514
|
+
name: anchor.name,
|
|
515
|
+
kind: "remote",
|
|
516
|
+
url: origin,
|
|
517
|
+
...(anchor.tls_cert !== undefined ? { tls_cert: anchor.tls_cert, tls_pin: anchor.tls_pin } : {}),
|
|
518
|
+
});
|
|
519
|
+
const door = await fetchDoorDocument(transport.base, {});
|
|
520
|
+
const unimported = door.doc.endpoints
|
|
521
|
+
.map((endpoint) => endpoint.name)
|
|
522
|
+
.filter((name) => !group.imported.has(name));
|
|
523
|
+
if (unimported.length > 0) {
|
|
524
|
+
notes.push(
|
|
525
|
+
`door ${origin}: ${unimported.length} unimported endpoint(s): ${unimported.join(", ")} — run 'ukp register --url ${origin}' to import`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
} catch {
|
|
529
|
+
// Door unreachable or not a door (anymore): the check is best-effort;
|
|
530
|
+
// endpoint rows carry their own `(unavailable)` degradation.
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return notes;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function executeUnregisterCommand(
|
|
537
|
+
args: readonly string[],
|
|
538
|
+
context: InventoryCommandContext,
|
|
539
|
+
): InventoryCommandResult {
|
|
540
|
+
if (isHelpRequest(args)) {
|
|
541
|
+
return { exitCode: 0, stdout: renderKitHelp(UNREGISTER_SPEC), stderr: "" };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
const parsed = parseKitArgs<UnregisterCommandOptions>(UNREGISTER_SPEC, args);
|
|
546
|
+
const positional = parsed.positionals[0];
|
|
547
|
+
if (parsed.options.endpoint && positional) {
|
|
548
|
+
throw new KitUsageError("unregister accepts either --endpoint <name> or legacy positional <name>, not both");
|
|
549
|
+
}
|
|
550
|
+
const name = parsed.options.endpoint ?? positional;
|
|
551
|
+
if (!name || !ENDPOINT_NAME.test(name)) {
|
|
552
|
+
throw new KitUsageError("unregister requires a valid endpoint name via --endpoint <name>");
|
|
553
|
+
}
|
|
554
|
+
const previous = readRegistry(context.registryPath).find((binding) => binding.name === name);
|
|
555
|
+
unregisterAt(context.registryPath, name);
|
|
556
|
+
return {
|
|
557
|
+
exitCode: 0,
|
|
558
|
+
stdout: `unregistered: ${name}\nlocation: ${previous?.path ?? previous?.url ?? "unknown"}`,
|
|
559
|
+
stderr: "",
|
|
560
|
+
};
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (error instanceof HelpRequestError) {
|
|
563
|
+
return { exitCode: 0, stdout: renderKitHelp(UNREGISTER_SPEC), stderr: "" };
|
|
564
|
+
}
|
|
565
|
+
if (error instanceof KitUsageError) {
|
|
566
|
+
return { exitCode: 2, stdout: "", stderr: renderKitUsageError(UNREGISTER_SPEC, error.message) };
|
|
567
|
+
}
|
|
568
|
+
return { exitCode: 1, stdout: "", stderr: `ukp unregister: ${error instanceof Error ? error.message : String(error)}` };
|
|
569
|
+
}
|
|
570
|
+
}
|