@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.
@@ -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
- assertRemoteUrlAllowed,
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, 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)" },
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 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.",
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 (ADR-REM-004):",
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); --select narrows the",
83
- " import; door growth shows in 'ukp list' as drift notes until imported.",
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): default is
201
- * every endpoint, `--select` narrows, name/url conflicts are skipped with a
202
- * visible reason (RQ-14: never silently renamed), and same name+url is the
203
- * idempotent refresh (TOFU pin + token, the W4 semantics in batch). ≥1
204
- * import/refresh exits 0; all-skipped exits 1. */
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 sameName = existing.find((endpoint) => endpoint.name === target.name);
218
- if (sameName !== undefined && !(sameName.kind === "remote" && sameName.url === url)) {
219
- lines.push(`skipped: ${target.name} (already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path})`);
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 sameUrl = existing.find(
223
- (endpoint) => endpoint.kind === "remote" && endpoint.url === url && endpoint.name !== target.name,
224
- );
225
- if (sameUrl !== undefined) {
226
- lines.push(`skipped: ${target.name} (url ${url} already bound to '${sameUrl.name}')`);
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: target.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
- sameName !== undefined
241
- ? `refreshed: ${target.name} (TOFU pin/credentials refreshed)`
242
- : `imported: ${target.name} (${declaredCapabilitySummary(target.capabilities)})`,
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 document at the url's origin
254
- * self-describes — an endpoint document (no `scope`) registers one endpoint
255
- * (today's behavior, name from the document per RQ-14, TOFU pin, --token in
256
- * the binding); a door document (`scope:"host"`) runs the import flow above.
257
- * ssh:// urls tunnel transparently; https urls probe TLS first (W5' / D-079):
258
- * a public-CA chain validates normally, a self-signed certificate is
259
- * TOFU-pinned into the binding(s) and verified on every later call. */
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
- assertRemoteUrlAllowed(url);
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 name = wellKnown.discovery.doc.name;
348
- if (!ENDPOINT_NAME.test(name)) {
349
- throw new Error(`discovery document declares an invalid endpoint name '${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(name);
473
+ const envToken = remoteTokenFor(handle);
364
474
  const lines = [
365
- `registered (remote): ${name}`,
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_${name.toUpperCase().replace(/-/g, "_")}_TOKEN overrides)`]
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_${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]
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
- group.imported.add(endpoint.name);
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)) {
@@ -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;
@@ -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
- { flags: "--pin <sha256-hex>", help: "verify rename recovery against this ukp-pin content hash (sha256-<64 hex>)" },
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 emitted next to ukp:// references)");
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 withPin = <T extends object>(request: T): T & { pin?: string } =>
284
- parsed.options.pin === undefined ? request : { ...request, pin: parsed.options.pin };
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: withPin(parseUkpUri(path, { endpoint: parsed.options.endpoint, lines: parsed.options.lines })), ...(format ? { format } : {}) };
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: withPin({
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: withPin({
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
  );
@@ -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({ registryPath: context.registryPath });
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) {
@@ -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({ registryPath: context.registryPath });
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);