@catheadowl/ukp 0.1.1 → 0.2.1
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 +34 -24
- package/bin/ukp.js +82 -0
- package/package.json +4 -5
- package/src/capabilities/nav.ts +4 -2
- package/src/capabilities/read.ts +33 -3
- package/src/capabilities/remote-client.ts +351 -60
- package/src/capabilities/rg.ts +2 -0
- package/src/capabilities/search.ts +25 -5
- package/src/capabilities/tls-identity.ts +172 -30
- package/src/cli.ts +1 -1
- package/src/commands/guide-content/client.txt +12 -0
- package/src/commands/guide-content/remote.txt +64 -0
- package/src/commands/guide-content/service-qmd.txt +1 -1
- package/src/commands/guide-content/service.txt +33 -32
- package/src/commands/guide.ts +11 -4
- package/src/commands/init.ts +9 -12
- package/src/commands/inventory.ts +189 -48
- package/src/commands/nav.ts +1 -1
- package/src/commands/read.ts +48 -9
- package/src/commands/rg.ts +4 -1
- package/src/commands/search.ts +4 -1
- package/src/commands/serve.ts +86 -12
- package/src/config/manifest.ts +6 -0
- package/src/registry.ts +104 -16
- package/src/server.ts +276 -46
- package/bun.lock +0 -35
- package/tsconfig.json +0 -12
|
@@ -2,7 +2,7 @@ import { ENDPOINT_NAME, loadManifest } from "../config/manifest.ts";
|
|
|
2
2
|
import { FILE_NATIVE_CAPABILITIES, isFileNativeCapability } from "../config/file-native.ts";
|
|
3
3
|
import { diagnoseService, type ProviderResolver } from "./diagnose.ts";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
assertRegistrableRemoteUrl,
|
|
6
6
|
isRemoteBinding,
|
|
7
7
|
parseRemoteUrl,
|
|
8
8
|
readRegistry,
|
|
@@ -56,7 +56,9 @@ export const REGISTER_SPEC: UkpCommandSpec = {
|
|
|
56
56
|
usage: "[options]",
|
|
57
57
|
strictArguments: true,
|
|
58
58
|
options: [
|
|
59
|
-
{ flags: "--url <url>", help: "register a remote ukp-serve endpoint (https
|
|
59
|
+
{ flags: "--url <url>", help: "register a remote ukp-serve endpoint (https://host[:port], ssh://host[/endpoint] — ssh takes no port, the woken door picks its own — 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: "--endpoint <name>", help: "assert the remote-declared name of the endpoint being registered (expected-name assertion; with a host door this imports only that endpoint)" },
|
|
61
|
+
{ flags: "--name <handle>", help: "remote only: register under this local handle — the registry key and ukp:// authority — instead of the declared name; use it when the declared name is already taken" },
|
|
60
62
|
{ 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
63
|
{ 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
64
|
],
|
|
@@ -70,17 +72,23 @@ export const REGISTER_SPEC: UkpCommandSpec = {
|
|
|
70
72
|
"Binding:",
|
|
71
73
|
" The Host Registry binds the Service Manifest's effective name to this",
|
|
72
74
|
" folder's location; 'ukp list' shows the resulting bindings.",
|
|
73
|
-
" With --url, the name and instance identity come from the
|
|
74
|
-
" discovery document (/.well-known/ukp.json) and are pinned
|
|
75
|
-
"
|
|
75
|
+
" With --url, the declared name and instance identity come from the",
|
|
76
|
+
" remote discovery document (/.well-known/ukp.json) and are pinned",
|
|
77
|
+
" TOFU-style as provenance; your registry binds them under a local",
|
|
78
|
+
" handle — the declared name by default, or --name <handle> when that",
|
|
79
|
+
" name is already taken. Use --endpoint <name> as an expected-name",
|
|
80
|
+
" assertion on the declared name; bearer-token services read",
|
|
81
|
+
" UKP_ENDPOINT_<NAME>_TOKEN (keyed by the handle) at call time.",
|
|
76
82
|
"",
|
|
77
|
-
"Host doors
|
|
83
|
+
"Host doors:",
|
|
78
84
|
" 'ukp register --url ssh://ali' fetches the door's discovery document",
|
|
79
85
|
" and imports every endpoint behind it (a door url may also name one",
|
|
80
86
|
" endpoint: ssh://ali/notes). Import is explicit and idempotent:",
|
|
81
87
|
" re-running refreshes TOFU pins and tokens. Name conflicts are skipped",
|
|
82
|
-
" with a visible reason (never silently renamed)
|
|
83
|
-
"
|
|
88
|
+
" with a visible reason (never silently renamed) — re-register that one",
|
|
89
|
+
" endpoint with --name to land it under another handle; --select narrows",
|
|
90
|
+
" the import; door growth shows in 'ukp list' as drift notes until",
|
|
91
|
+
" imported.",
|
|
84
92
|
"",
|
|
85
93
|
].join("\n"),
|
|
86
94
|
};
|
|
@@ -146,14 +154,30 @@ export function executeRegisterCommand(
|
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
try {
|
|
149
|
-
const parsed = parseKitArgs<{ url?: string; token?: string; select?: string }>(REGISTER_SPEC, args);
|
|
157
|
+
const parsed = parseKitArgs<{ url?: string; token?: string; select?: string; endpoint?: string; name?: string }>(REGISTER_SPEC, args);
|
|
158
|
+
const expectedName = parsed.options.endpoint;
|
|
159
|
+
if (expectedName !== undefined && !ENDPOINT_NAME.test(expectedName)) {
|
|
160
|
+
throw new KitUsageError("--endpoint requires a valid endpoint name");
|
|
161
|
+
}
|
|
162
|
+
const handleName = parsed.options.name;
|
|
163
|
+
if (handleName !== undefined && !ENDPOINT_NAME.test(handleName)) {
|
|
164
|
+
throw new KitUsageError("--name requires a valid endpoint name");
|
|
165
|
+
}
|
|
166
|
+
if (handleName !== undefined && parsed.options.url === undefined) {
|
|
167
|
+
// N-1 (ADR-REM-007): a local name lives in the Service Manifest the
|
|
168
|
+
// folder owner controls; the registry naming slot is remote-only.
|
|
169
|
+
throw new KitUsageError("--name chooses a remote registration handle and requires --url");
|
|
170
|
+
}
|
|
150
171
|
if (parsed.options.url !== undefined) {
|
|
151
|
-
return executeRegisterRemote(parsed.options.url, parsed.options.token, parsed.options.select, context);
|
|
172
|
+
return executeRegisterRemote(parsed.options.url, parsed.options.token, parsed.options.select, expectedName, handleName, context);
|
|
152
173
|
}
|
|
153
174
|
if (parsed.options.select !== undefined) {
|
|
154
175
|
throw new KitUsageError("--select requires --url <door url>");
|
|
155
176
|
}
|
|
156
177
|
const report = diagnoseService(context.currentDirectory, context.resolveProvider);
|
|
178
|
+
if (expectedName !== undefined && expectedName !== report.service.effectiveName) {
|
|
179
|
+
throw new Error(`expected endpoint '${expectedName}', but this Service effective name is '${report.service.effectiveName}'`);
|
|
180
|
+
}
|
|
157
181
|
registerAt(context.registryPath, report.service.effectiveName, report.service.folder);
|
|
158
182
|
const lines = [
|
|
159
183
|
`registered: ${report.service.effectiveName}`,
|
|
@@ -197,37 +221,71 @@ function declaredCapabilitySummary(capabilities: Record<string, unknown>): strin
|
|
|
197
221
|
return extras.length > 0 ? extras.join(",") : "-";
|
|
198
222
|
}
|
|
199
223
|
|
|
200
|
-
/** Import the door roster into the registry (ADR-REM-004 / O-4
|
|
201
|
-
* every endpoint, `--select`
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
224
|
+
/** Import the door roster into the registry (ADR-REM-004 / O-4; naming
|
|
225
|
+
* residence ADR-REM-007 / D-086): default is every endpoint, `--select`
|
|
226
|
+
* narrows, conflicts are skipped with a visible reason plus the
|
|
227
|
+
* single-endpoint `--name` remedy (never silently renamed), and same
|
|
228
|
+
* url+declared name is the idempotent refresh under the existing handle
|
|
229
|
+
* (TOFU pin + token, the W4 semantics in batch). ≥1 import/refresh exits 0;
|
|
230
|
+
* all-skipped exits 1. */
|
|
205
231
|
function importDoorEndpoints(
|
|
206
232
|
origin: string,
|
|
207
233
|
door: { endpoints: ReadonlyArray<{ name: string; instance_uid: string; capabilities: Record<string, unknown> }> },
|
|
208
234
|
targets: ReadonlyArray<{ name: string; instance_uid: string; capabilities: Record<string, unknown> }>,
|
|
209
235
|
credentials: { token?: string; tls_cert?: string; tls_pin?: string },
|
|
210
236
|
context: InventoryCommandContext,
|
|
237
|
+
handleOverride?: string,
|
|
211
238
|
): InventoryCommandResult {
|
|
212
239
|
const existing = readRegistry(context.registryPath);
|
|
213
240
|
const lines = [`door ${origin}: ${door.endpoints.length} endpoint(s)`];
|
|
214
241
|
let landed = 0;
|
|
215
242
|
for (const target of targets) {
|
|
243
|
+
// N-5 (ADR-REM-007): the url path segment stays the door-declared name —
|
|
244
|
+
// the door routes by it; only the local handle may differ.
|
|
216
245
|
const url = doorEndpointUrl(origin, target.name);
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
246
|
+
const handle = handleOverride ?? target.name;
|
|
247
|
+
// Mirror of registerRemoteBinding's same-url branch (registry.ts):
|
|
248
|
+
// skip-line wording lives here, refusal semantics there — keep in step.
|
|
249
|
+
const sameUrl = existing.find((endpoint) => endpoint.kind === "remote" && endpoint.url === url);
|
|
250
|
+
if (sameUrl !== undefined) {
|
|
251
|
+
// Legacy bindings carry no declared_name; their handle stood in for it.
|
|
252
|
+
const existingDeclared = sameUrl.declared_name ?? sameUrl.name;
|
|
253
|
+
if (existingDeclared !== target.name) {
|
|
254
|
+
lines.push(`skipped: ${target.name} (url ${url} is bound to '${sameUrl.name}' declaring '${existingDeclared}')`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// A default gesture (no --name) refreshes the instance under its
|
|
258
|
+
// EXISTING handle (N-2); an explicit --name must match it — explicit
|
|
259
|
+
// means explicit, renaming goes through unregister.
|
|
260
|
+
if (sameUrl.name !== handle && (handleOverride !== undefined || handle !== target.name)) {
|
|
261
|
+
lines.push(`skipped: ${target.name} (url ${url} already bound to '${sameUrl.name}'; unregister it to change the handle)`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
registerRemoteAt(context.registryPath, {
|
|
265
|
+
name: sameUrl.name,
|
|
266
|
+
declared_name: target.name,
|
|
267
|
+
url,
|
|
268
|
+
instance_uid: target.instance_uid,
|
|
269
|
+
...(credentials.token !== undefined ? { token: credentials.token } : {}),
|
|
270
|
+
...(credentials.tls_cert !== undefined && credentials.tls_pin !== undefined
|
|
271
|
+
? { tls_cert: credentials.tls_cert, tls_pin: credentials.tls_pin }
|
|
272
|
+
: {}),
|
|
273
|
+
});
|
|
274
|
+
landed += 1;
|
|
275
|
+
lines.push(`refreshed: ${sameUrl.name} (TOFU pin/credentials refreshed)`);
|
|
220
276
|
continue;
|
|
221
277
|
}
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
const sameName = existing.find((endpoint) => endpoint.name === handle);
|
|
279
|
+
if (sameName !== undefined) {
|
|
280
|
+
lines.push(
|
|
281
|
+
`skipped: ${target.name} (name '${handle}' already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}; `
|
|
282
|
+
+ `re-register this endpoint with --name <handle> to land it under another)`,
|
|
283
|
+
);
|
|
227
284
|
continue;
|
|
228
285
|
}
|
|
229
286
|
registerRemoteAt(context.registryPath, {
|
|
230
|
-
name:
|
|
287
|
+
name: handle,
|
|
288
|
+
declared_name: target.name,
|
|
231
289
|
url,
|
|
232
290
|
instance_uid: target.instance_uid,
|
|
233
291
|
...(credentials.token !== undefined ? { token: credentials.token } : {}),
|
|
@@ -237,9 +295,9 @@ function importDoorEndpoints(
|
|
|
237
295
|
});
|
|
238
296
|
landed += 1;
|
|
239
297
|
lines.push(
|
|
240
|
-
|
|
241
|
-
? `
|
|
242
|
-
: `imported: ${
|
|
298
|
+
handle !== target.name
|
|
299
|
+
? `imported: ${handle} (declares ${target.name}; ${declaredCapabilitySummary(target.capabilities)})`
|
|
300
|
+
: `imported: ${handle} (${declaredCapabilitySummary(target.capabilities)})`,
|
|
243
301
|
);
|
|
244
302
|
}
|
|
245
303
|
return {
|
|
@@ -250,17 +308,21 @@ function importDoorEndpoints(
|
|
|
250
308
|
}
|
|
251
309
|
|
|
252
310
|
/** Remote registration (D-077/D-078 / spec endpoint-registration; host doors
|
|
253
|
-
* since W7 / ADR-REM-004): the well-known
|
|
254
|
-
* self-describes — an endpoint document (no
|
|
255
|
-
* (
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
311
|
+
* since W7 / ADR-REM-004; naming residence ADR-REM-007): the well-known
|
|
312
|
+
* document at the url's origin self-describes — an endpoint document (no
|
|
313
|
+
* `scope`) registers one endpoint (declared name from the document as
|
|
314
|
+
* provenance, local handle = declared name or --name, TOFU pin, --token in
|
|
315
|
+
* the binding); a door document (`scope:"host"`) runs the import flow above
|
|
316
|
+
* (--name only when exactly one target results). ssh:// urls tunnel
|
|
317
|
+
* transparently; https urls probe TLS first (W5' / D-079): a public-CA chain
|
|
318
|
+
* validates normally, a self-signed certificate is TOFU-pinned into the
|
|
319
|
+
* binding(s) and verified on every later call. */
|
|
260
320
|
async function executeRegisterRemote(
|
|
261
321
|
url: string,
|
|
262
322
|
token: string | undefined,
|
|
263
323
|
select: string | undefined,
|
|
324
|
+
expectedName: string | undefined,
|
|
325
|
+
handleName: string | undefined,
|
|
264
326
|
context: InventoryCommandContext,
|
|
265
327
|
): Promise<InventoryCommandResult> {
|
|
266
328
|
const selected = select === undefined
|
|
@@ -269,10 +331,15 @@ async function executeRegisterRemote(
|
|
|
269
331
|
if (selected !== undefined && selected.length === 0) {
|
|
270
332
|
throw new KitUsageError("--select requires at least one endpoint name");
|
|
271
333
|
}
|
|
334
|
+
if (selected !== undefined && expectedName !== undefined) {
|
|
335
|
+
throw new KitUsageError("--endpoint and --select cannot be used together");
|
|
336
|
+
}
|
|
272
337
|
|
|
273
338
|
let pool: RemoteTransportPool | undefined;
|
|
274
339
|
try {
|
|
275
|
-
|
|
340
|
+
// D-089: registration intake rejects an explicit ssh:// port (dead
|
|
341
|
+
// grammar since the W9 wake); parse-time surfaces stay permissive.
|
|
342
|
+
assertRegistrableRemoteUrl(url);
|
|
276
343
|
const parts = parseRemoteUrl(url);
|
|
277
344
|
if (parts === undefined) {
|
|
278
345
|
throw new Error(`remote endpoint url is not admissible: ${url}`);
|
|
@@ -296,6 +363,11 @@ async function executeRegisterRemote(
|
|
|
296
363
|
const rosterNames = roster.map((endpoint) => endpoint.name).join(", ");
|
|
297
364
|
let targets;
|
|
298
365
|
if (parts.endpointName !== undefined) {
|
|
366
|
+
if (expectedName !== undefined && expectedName !== parts.endpointName) {
|
|
367
|
+
throw new KitUsageError(
|
|
368
|
+
`remote url selects endpoint '${parts.endpointName}', but --endpoint asserts '${expectedName}'`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
299
371
|
// A path segment addresses ONE endpoint through the door (H-rehearsal):
|
|
300
372
|
// membership is checked against the live roster, unknown names say so.
|
|
301
373
|
const found = roster.find((endpoint) => endpoint.name === parts.endpointName);
|
|
@@ -303,6 +375,12 @@ async function executeRegisterRemote(
|
|
|
303
375
|
throw new Error(`no such endpoint '${parts.endpointName}' on door ${parts.origin} (available: ${rosterNames})`);
|
|
304
376
|
}
|
|
305
377
|
targets = [found];
|
|
378
|
+
} else if (expectedName !== undefined) {
|
|
379
|
+
const found = roster.find((endpoint) => endpoint.name === expectedName);
|
|
380
|
+
if (found === undefined) {
|
|
381
|
+
throw new Error(`no such endpoint '${expectedName}' on door ${parts.origin} (available: ${rosterNames})`);
|
|
382
|
+
}
|
|
383
|
+
targets = [found];
|
|
306
384
|
} else if (selected !== undefined) {
|
|
307
385
|
const unknown = selected.filter((name) => !roster.some((endpoint) => endpoint.name === name));
|
|
308
386
|
if (unknown.length > 0) {
|
|
@@ -314,6 +392,11 @@ async function executeRegisterRemote(
|
|
|
314
392
|
} else {
|
|
315
393
|
targets = [...roster];
|
|
316
394
|
}
|
|
395
|
+
if (handleName !== undefined && targets.length !== 1) {
|
|
396
|
+
throw new KitUsageError(
|
|
397
|
+
`--name applies to a single endpoint, but this import targets ${targets.length}; narrow with --select/--endpoint or drop --name`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
317
400
|
const result = importDoorEndpoints(
|
|
318
401
|
parts.origin,
|
|
319
402
|
{ endpoints: roster },
|
|
@@ -325,6 +408,7 @@ async function executeRegisterRemote(
|
|
|
325
408
|
: {}),
|
|
326
409
|
},
|
|
327
410
|
context,
|
|
411
|
+
handleName,
|
|
328
412
|
);
|
|
329
413
|
const nameHint = wellKnown.door.bearerRequired && token === undefined
|
|
330
414
|
? [`note: this door requires a bearer token; pass --token or set UKP_ENDPOINT_<NAME>_TOKEN per endpoint`]
|
|
@@ -344,15 +428,41 @@ async function executeRegisterRemote(
|
|
|
344
428
|
if (selected !== undefined) {
|
|
345
429
|
throw new KitUsageError(`--select requires a host door url: ${parts.origin} serves a single endpoint`);
|
|
346
430
|
}
|
|
347
|
-
const
|
|
348
|
-
if (!ENDPOINT_NAME.test(
|
|
349
|
-
throw new Error(`discovery document declares an invalid endpoint name '${
|
|
431
|
+
const declaredName = wellKnown.discovery.doc.name;
|
|
432
|
+
if (!ENDPOINT_NAME.test(declaredName)) {
|
|
433
|
+
throw new Error(`discovery document declares an invalid endpoint name '${declaredName}'`);
|
|
434
|
+
}
|
|
435
|
+
if (expectedName !== undefined && expectedName !== declaredName) {
|
|
436
|
+
throw new Error(`expected endpoint '${expectedName}', but discovery document declares '${declaredName}'`);
|
|
350
437
|
}
|
|
351
438
|
if (wellKnown.discovery.doc.instance_uid === undefined || typeof wellKnown.discovery.doc.instance_uid !== "string") {
|
|
352
439
|
throw new Error("discovery document carries no instance_uid; the service must run a ukp-remote v1 server");
|
|
353
440
|
}
|
|
441
|
+
// ADR-REM-007 / D-086: the handle is the consumer-chosen local name
|
|
442
|
+
// (defaults to the declared name); the declared name is stored as
|
|
443
|
+
// provenance and stays the target of the expected-name assertion above.
|
|
444
|
+
// N-2: a default-gesture re-registration of an already-tracked instance
|
|
445
|
+
// (same url, same declared name) refreshes it under its EXISTING handle —
|
|
446
|
+
// an explicitly different --name is refused first (explicit means
|
|
447
|
+
// explicit; renaming goes through unregister).
|
|
448
|
+
const requestedHandle = handleName ?? declaredName;
|
|
449
|
+
const tracked = readRegistry(context.registryPath).find(
|
|
450
|
+
(endpoint) => endpoint.kind === "remote" && endpoint.url === url,
|
|
451
|
+
);
|
|
452
|
+
if (
|
|
453
|
+
tracked !== undefined && handleName !== undefined && handleName !== tracked.name
|
|
454
|
+
&& (tracked.declared_name ?? tracked.name) === declaredName
|
|
455
|
+
) {
|
|
456
|
+
throw new Error(`remote endpoint ${url} is already registered as '${tracked.name}'; unregister it first to change the handle`);
|
|
457
|
+
}
|
|
458
|
+
const handle = tracked !== undefined
|
|
459
|
+
&& (tracked.declared_name ?? tracked.name) === declaredName
|
|
460
|
+
&& requestedHandle === declaredName
|
|
461
|
+
? tracked.name
|
|
462
|
+
: requestedHandle;
|
|
354
463
|
registerRemoteAt(context.registryPath, {
|
|
355
|
-
name,
|
|
464
|
+
name: handle,
|
|
465
|
+
declared_name: declaredName,
|
|
356
466
|
url,
|
|
357
467
|
instance_uid: wellKnown.discovery.doc.instance_uid,
|
|
358
468
|
...(token !== undefined ? { token } : {}),
|
|
@@ -360,9 +470,10 @@ async function executeRegisterRemote(
|
|
|
360
470
|
? { tls_cert: tlsProbe.certPem, tls_pin: tlsProbe.spkiPin }
|
|
361
471
|
: {}),
|
|
362
472
|
});
|
|
363
|
-
const envToken = remoteTokenFor(
|
|
473
|
+
const envToken = remoteTokenFor(handle);
|
|
364
474
|
const lines = [
|
|
365
|
-
`registered (remote): ${
|
|
475
|
+
`registered (remote): ${handle}`,
|
|
476
|
+
...(handle !== declaredName ? [`declares: ${declaredName} (remote-declared name, stored as provenance)`] : []),
|
|
366
477
|
`url: ${url}`,
|
|
367
478
|
`instance_uid: ${wellKnown.discovery.doc.instance_uid}`,
|
|
368
479
|
...(pinSelfSigned && tlsProbe !== undefined
|
|
@@ -370,9 +481,9 @@ async function executeRegisterRemote(
|
|
|
370
481
|
: []),
|
|
371
482
|
...(wellKnown.discovery.doc.description !== undefined ? [`description: ${wellKnown.discovery.doc.description}`] : []),
|
|
372
483
|
...(token !== undefined
|
|
373
|
-
? [`auth: token stored in registry binding (plaintext; env UKP_ENDPOINT_${
|
|
484
|
+
? [`auth: token stored in registry binding (plaintext; env UKP_ENDPOINT_${handle.toUpperCase().replace(/-/g, "_")}_TOKEN overrides)`]
|
|
374
485
|
: wellKnown.discovery.bearerRequired && envToken === undefined
|
|
375
|
-
? [`note: this endpoint requires a bearer token; pass --token or set UKP_ENDPOINT_${
|
|
486
|
+
? [`note: this endpoint requires a bearer token; pass --token or set UKP_ENDPOINT_${handle.toUpperCase().replace(/-/g, "_")}_TOKEN`]
|
|
376
487
|
: []),
|
|
377
488
|
];
|
|
378
489
|
return { exitCode: 0, stdout: lines.join("\n"), stderr: "" };
|
|
@@ -424,7 +535,7 @@ export function executeListCommand(
|
|
|
424
535
|
rendered.push(await renderRemoteListRow(endpoint, pool));
|
|
425
536
|
}
|
|
426
537
|
const drift = await collectDoorDriftNotes(endpoints, pool);
|
|
427
|
-
return renderListOutput(rendered, drift);
|
|
538
|
+
return renderListOutput(rendered, [...collectSameInstanceNotes(endpoints), ...drift]);
|
|
428
539
|
} finally {
|
|
429
540
|
pool.close();
|
|
430
541
|
}
|
|
@@ -460,12 +571,16 @@ function renderListOutput(
|
|
|
460
571
|
* endpoints degrade to `(unavailable)` + one stderr warning — the same
|
|
461
572
|
* inventory semantics as an unreadable local manifest. Door-form bindings
|
|
462
573
|
* (`ssh://ali/notes`) fetch their per-endpoint document through the shared
|
|
463
|
-
* pool's tunnel.
|
|
574
|
+
* pool's tunnel. A handle that differs from the declared name (ADR-REM-007)
|
|
575
|
+
* carries the declaration inline for audit. */
|
|
464
576
|
async function renderRemoteListRow(
|
|
465
577
|
endpoint: RegistryBinding,
|
|
466
578
|
pool: RemoteTransportPool,
|
|
467
579
|
): Promise<{ line: string; warning?: string }> {
|
|
468
580
|
const url = endpoint.url!;
|
|
581
|
+
const declaredNote = endpoint.declared_name !== undefined && endpoint.declared_name !== endpoint.name
|
|
582
|
+
? ` (declares ${endpoint.declared_name})`
|
|
583
|
+
: "";
|
|
469
584
|
try {
|
|
470
585
|
const transport = await pool.acquire(endpoint);
|
|
471
586
|
const discovery = await fetchDiscoveryDocument(endpoint, transport, resolveRemoteToken(endpoint));
|
|
@@ -474,16 +589,40 @@ async function renderRemoteListRow(
|
|
|
474
589
|
.map(([name]) => name)
|
|
475
590
|
.sort();
|
|
476
591
|
const warnings = discovery.warnings.length > 0 ? { warning: discovery.warnings.join("; ") } : {};
|
|
477
|
-
return { line: `${endpoint.name}\t${url}\t${extras.length > 0 ? extras.join(",") : "-"}`, ...warnings };
|
|
592
|
+
return { line: `${endpoint.name}${declaredNote}\t${url}\t${extras.length > 0 ? extras.join(",") : "-"}`, ...warnings };
|
|
478
593
|
} catch (error) {
|
|
479
594
|
const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
480
595
|
return {
|
|
481
|
-
line: `${endpoint.name}\t${url}\t(unavailable)`,
|
|
596
|
+
line: `${endpoint.name}${declaredNote}\t${url}\t(unavailable)`,
|
|
482
597
|
warning: `endpoint '${endpoint.name}' capabilities unavailable: ${headline}`,
|
|
483
598
|
};
|
|
484
599
|
}
|
|
485
600
|
}
|
|
486
601
|
|
|
602
|
+
/** Same-instance double-handle note (ADR-REM-007 / N-2): two handles may
|
|
603
|
+
* legitimately point at one service through different urls (e.g. an ssh door
|
|
604
|
+
* and an https door of the same host); the shared instance_uid makes it
|
|
605
|
+
* visible. A note, not an error — each handle is a valid addressing surface,
|
|
606
|
+
* but ukp:// references under the two handles are not interchangeable. */
|
|
607
|
+
function collectSameInstanceNotes(endpoints: readonly RegistryBinding[]): string[] {
|
|
608
|
+
const byUid = new Map<string, string[]>();
|
|
609
|
+
for (const endpoint of endpoints) {
|
|
610
|
+
if (!isRemoteBinding(endpoint) || endpoint.instance_uid === undefined) continue;
|
|
611
|
+
const handles = byUid.get(endpoint.instance_uid) ?? [];
|
|
612
|
+
handles.push(endpoint.name);
|
|
613
|
+
byUid.set(endpoint.instance_uid, handles);
|
|
614
|
+
}
|
|
615
|
+
const notes: string[] = [];
|
|
616
|
+
for (const [uid, handles] of byUid) {
|
|
617
|
+
if (handles.length > 1) {
|
|
618
|
+
notes.push(
|
|
619
|
+
`note: ${[...handles].sort().map((handle) => `'${handle}'`).join(" and ")} pin the same instance_uid ${uid} — one service under two handles`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return notes;
|
|
624
|
+
}
|
|
625
|
+
|
|
487
626
|
/** Door drift notes (ADR-REM-004 / O-4): group door-form bindings by their
|
|
488
627
|
* url origin, fetch each door's document ONCE per invocation (riding the
|
|
489
628
|
* pool's shared tunnel), and note the endpoints the door serves that the
|
|
@@ -505,7 +644,9 @@ async function collectDoorDriftNotes(
|
|
|
505
644
|
origins.set(parts.origin, group);
|
|
506
645
|
}
|
|
507
646
|
group.members.push(endpoint);
|
|
508
|
-
|
|
647
|
+
// The roster speaks declared names; a handle that differs from the
|
|
648
|
+
// declared name (ADR-REM-007) must not read as "not imported".
|
|
649
|
+
group.imported.add(endpoint.declared_name ?? endpoint.name);
|
|
509
650
|
}
|
|
510
651
|
const notes: string[] = [];
|
|
511
652
|
for (const [origin, group] of [...origins.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
|
package/src/commands/nav.ts
CHANGED
|
@@ -125,7 +125,7 @@ export function executeNavCommand(
|
|
|
125
125
|
if (error instanceof HelpRequestError) {
|
|
126
126
|
return { exitCode: 0, stdout: renderNavHelp(), stderr: "" };
|
|
127
127
|
}
|
|
128
|
-
if (error instanceof KitUsageError) {
|
|
128
|
+
if (error instanceof KitUsageError || error instanceof NavUsageError) {
|
|
129
129
|
return { exitCode: 2, stdout: "", stderr: renderNavUsageError(error.message) };
|
|
130
130
|
}
|
|
131
131
|
throw error;
|
package/src/commands/read.ts
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
type ReadRequest,
|
|
12
12
|
type LineRange,
|
|
13
13
|
} from "../capabilities/read.ts";
|
|
14
|
-
import { isValidPin } from "../capabilities/rename-recovery.ts";
|
|
14
|
+
import { isValidPin, pinHashOf } from "../capabilities/rename-recovery.ts";
|
|
15
|
+
import { isBareDocidReference, stripDocidHash } from "../capabilities/qmd.ts";
|
|
15
16
|
import {
|
|
16
17
|
fetchDiscoveryDocument,
|
|
17
18
|
openRemoteTransport,
|
|
@@ -89,7 +90,14 @@ export const READ_SPEC: UkpCommandSpec = {
|
|
|
89
90
|
flags: "--from <route>",
|
|
90
91
|
help: "resolve a document-relative reference (../x.md, bare filename) against this source document's endpoint-relative route",
|
|
91
92
|
},
|
|
92
|
-
{
|
|
93
|
+
{
|
|
94
|
+
flags: "--pin <sha256-hex>",
|
|
95
|
+
help: "verify rename recovery against this ukp-pin content hash (sha256-<64 hex>; emit the current pin with --show-pin)",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
flags: "--show-pin",
|
|
99
|
+
help: "emit the ukp-pin for the read resource on stderr (<!-- ukp-pin: sha256-… -->, whole-file LF-normalized sha256; embed next to a ukp:// reference to power rename recovery). Local reads allow a line window — the pin still covers the whole file; remote reads need the whole file, so drop the window there",
|
|
100
|
+
},
|
|
93
101
|
{ flags: "--format <mode>", help: "output mode: 'json' emits a structured failure envelope (body still goes to stdout); default is human output" },
|
|
94
102
|
],
|
|
95
103
|
helpSuffix: [
|
|
@@ -111,6 +119,7 @@ interface ReadCommandOptions extends Record<string, unknown> {
|
|
|
111
119
|
lines?: string;
|
|
112
120
|
from?: string;
|
|
113
121
|
pin?: string;
|
|
122
|
+
showPin?: boolean;
|
|
114
123
|
format?: string;
|
|
115
124
|
}
|
|
116
125
|
|
|
@@ -266,11 +275,22 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
|
|
|
266
275
|
if (parsed.options.pin !== undefined && !isValidPin(parsed.options.pin)) {
|
|
267
276
|
// Q7-narrowed SRI form only: no multi-algorithm negotiation, no upper-case
|
|
268
277
|
// hex tolerance — the pin is machine-written, not hand-typed.
|
|
269
|
-
throw new KitUsageError("--pin must use sha256-<64 lowercase hex> (the ukp-pin form
|
|
278
|
+
throw new KitUsageError("--pin must use sha256-<64 lowercase hex> (the ukp-pin form embedded next to ukp:// references; emit one with --show-pin)");
|
|
270
279
|
}
|
|
271
280
|
if (parsed.options.format !== undefined && parsed.options.format !== "json") {
|
|
272
281
|
throw new KitUsageError("--format only supports 'json'");
|
|
273
282
|
}
|
|
283
|
+
// The ukp-pin is a file-slot concept (same boundary as pin verification in
|
|
284
|
+
// the recovery descent): provider-tier references never expose whole-file
|
|
285
|
+
// content, so emission is refused by input shape at parse time.
|
|
286
|
+
if (parsed.options.showPin === true && path !== undefined) {
|
|
287
|
+
const barePath = stripDocidHash(path);
|
|
288
|
+
if (path.startsWith("qmd://") || isBareDocidReference(barePath)) {
|
|
289
|
+
throw new KitUsageError(
|
|
290
|
+
"--show-pin is file-slot-only: the ukp-pin covers whole-file content, and provider references (docid, qmd://) do not expose it",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
274
294
|
const pin = parsed.options.pin;
|
|
275
295
|
if (parsed.options.g) throw new KitUsageError("read requires --endpoint <name> and does not support -g");
|
|
276
296
|
// Unexpected positionals are rejected before the missing-flag checks so the
|
|
@@ -280,15 +300,19 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
|
|
|
280
300
|
`unexpected argument '${unexpected}'; read accepts exactly one reference. Use '--endpoint <name>' to select an endpoint.`,
|
|
281
301
|
);
|
|
282
302
|
}
|
|
283
|
-
const
|
|
284
|
-
|
|
303
|
+
const withPinOptions = <T extends object>(request: T): T & { pin?: string; emitPin?: boolean } => {
|
|
304
|
+
let out: T & { pin?: string; emitPin?: boolean } = request;
|
|
305
|
+
if (parsed.options.pin !== undefined) out = { ...out, pin: parsed.options.pin };
|
|
306
|
+
if (parsed.options.showPin === true) out = { ...out, emitPin: true };
|
|
307
|
+
return out;
|
|
308
|
+
};
|
|
285
309
|
const format = parsed.options.format === "json" ? ("json" as const) : undefined;
|
|
286
310
|
|
|
287
311
|
if (path !== undefined && UKP_URI_PREFIX_RE.test(path)) {
|
|
288
312
|
if (parsed.options.from !== undefined) {
|
|
289
313
|
throw new KitUsageError("a ukp:// URI carries its own endpoint; --from is for document-relative references");
|
|
290
314
|
}
|
|
291
|
-
return { request:
|
|
315
|
+
return { request: withPinOptions(parseUkpUri(path, { endpoint: parsed.options.endpoint, lines: parsed.options.lines })), ...(format ? { format } : {}) };
|
|
292
316
|
}
|
|
293
317
|
// Tolerant tier (ADR-URI-001): an absolute filesystem path carries its own
|
|
294
318
|
// endpoint (Registry-matched), so --endpoint is neither required nor
|
|
@@ -303,7 +327,7 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
|
|
|
303
327
|
);
|
|
304
328
|
}
|
|
305
329
|
return {
|
|
306
|
-
request:
|
|
330
|
+
request: withPinOptions({
|
|
307
331
|
path,
|
|
308
332
|
...(parsed.options.lines === undefined ? {} : { lines: parseLineRange(parsed.options.lines) }),
|
|
309
333
|
}),
|
|
@@ -321,7 +345,7 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
|
|
|
321
345
|
}
|
|
322
346
|
|
|
323
347
|
return {
|
|
324
|
-
request:
|
|
348
|
+
request: withPinOptions({
|
|
325
349
|
endpoint: parsed.options.endpoint,
|
|
326
350
|
path,
|
|
327
351
|
...(parsed.options.from === undefined ? {} : { fromRef: parsed.options.from }),
|
|
@@ -450,6 +474,17 @@ async function executeRemoteRead(
|
|
|
450
474
|
binding: RegistryBinding,
|
|
451
475
|
registryPath?: string,
|
|
452
476
|
): Promise<ReadCommandResult> {
|
|
477
|
+
// Remote emission needs whole-file content, and a line window would arrive
|
|
478
|
+
// windowed from the server — refuse before opening any transport.
|
|
479
|
+
if (request.emitPin === true && request.lines !== undefined) {
|
|
480
|
+
return {
|
|
481
|
+
exitCode: 2,
|
|
482
|
+
stdout: "",
|
|
483
|
+
stderr: renderReadUsageError(
|
|
484
|
+
"--show-pin needs whole-file content; drop the line window (--lines / #L) — the pin always covers the whole file",
|
|
485
|
+
),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
453
488
|
const token = resolveRemoteToken(binding);
|
|
454
489
|
const warnings: string[] = [];
|
|
455
490
|
let transport: RemoteTransportHandle | undefined;
|
|
@@ -516,9 +551,13 @@ async function executeRemoteRead(
|
|
|
516
551
|
transport.close();
|
|
517
552
|
}
|
|
518
553
|
if (result.ok) {
|
|
554
|
+
// No line window can be present here (rejected above), so the server
|
|
555
|
+
// returned the whole file — the client-side hash is the same contract as
|
|
556
|
+
// the local capability's pre-window computation.
|
|
557
|
+
const pin = request.emitPin === true ? `sha256-${pinHashOf(result.content)}` : undefined;
|
|
519
558
|
return renderReadOutcome(
|
|
520
559
|
request,
|
|
521
|
-
{ ok: true, result: { content: result.content, recoveryWarnings: [] } },
|
|
560
|
+
{ ok: true, result: { content: result.content, ...(pin !== undefined ? { pin } : {}), recoveryWarnings: [] } },
|
|
522
561
|
format,
|
|
523
562
|
warnings,
|
|
524
563
|
);
|
package/src/commands/rg.ts
CHANGED
|
@@ -282,7 +282,10 @@ async function executeMixedRg(
|
|
|
282
282
|
const remoteOutcomes = new Map<string, RgEndpointOutcome>();
|
|
283
283
|
// One transport pool for the whole invocation (W7 / O-5): same-origin door
|
|
284
284
|
// bindings share a single ssh tunnel instead of one per row.
|
|
285
|
-
const pool = createTransportPool({
|
|
285
|
+
const pool = createTransportPool({
|
|
286
|
+
registryPath: context.registryPath,
|
|
287
|
+
...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
|
|
288
|
+
});
|
|
286
289
|
try {
|
|
287
290
|
for (const binding of remotes) {
|
|
288
291
|
if (interrupted) {
|
package/src/commands/search.ts
CHANGED
|
@@ -223,7 +223,10 @@ async function executeMixedSearch(
|
|
|
223
223
|
const remoteOutcomes = new Map<string, SearchEndpointOutcome>();
|
|
224
224
|
// One transport pool for the whole invocation (W7 / O-5): same-origin door
|
|
225
225
|
// bindings share a single ssh tunnel instead of one per row.
|
|
226
|
-
const pool = createTransportPool({
|
|
226
|
+
const pool = createTransportPool({
|
|
227
|
+
registryPath: context.registryPath,
|
|
228
|
+
...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
|
|
229
|
+
});
|
|
227
230
|
try {
|
|
228
231
|
for (const binding of remotes) {
|
|
229
232
|
const token = resolveRemoteToken(binding);
|