@catheadowl/ukp 0.2.3 → 0.2.4
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/bin/ukp.js +31 -11
- package/package.json +1 -1
- package/src/capabilities/propose.ts +1 -1
- package/src/capabilities/read.ts +1 -1
- package/src/capabilities/remote-client.ts +163 -52
- package/src/capabilities/rg.ts +1 -1
- package/src/capabilities/search.ts +2 -2
- package/src/cli.ts +72 -3
- package/src/commands/guide-content/propose.txt +1 -1
- package/src/commands/guide-content/remote.txt +6 -6
- package/src/commands/guide-content/service-qmd.txt +1 -1
- package/src/commands/inventory.ts +182 -36
- package/src/commands/propose.ts +1 -1
- package/src/commands/read.ts +6 -6
- package/src/commands/serve-task.ts +17 -17
- package/src/commands/serve.ts +22 -22
- package/src/registry.ts +2 -2
- package/src/server.ts +4 -4
package/src/cli.ts
CHANGED
|
@@ -186,7 +186,7 @@ export function renderHelp(): string {
|
|
|
186
186
|
const lines = [
|
|
187
187
|
"Usage: ukp <command> [options]",
|
|
188
188
|
"",
|
|
189
|
-
"Start here: 'ukp guide service' (first setup
|
|
189
|
+
"Start here: 'ukp guide service' (first setup - covers init and register) or 'ukp list' (see registered endpoints).",
|
|
190
190
|
"",
|
|
191
191
|
"Commands:",
|
|
192
192
|
...COMMAND_GROUPS.flatMap((group) => [
|
|
@@ -222,6 +222,51 @@ function writeCommandResult(
|
|
|
222
222
|
return result.exitCode;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/** Channel philosophy for `ukp list` (ukp_list W2): stdout is the data
|
|
226
|
+
* channel — byte contract, one row per line, the terminal wraps what it
|
|
227
|
+
* must; stderr to an INTERACTIVE terminal is the human channel — progress
|
|
228
|
+
* frames already live there, and the footnote lines (degradation warnings,
|
|
229
|
+
* drift notes) may exceed any terminal width (pins are ~44 chars, urls
|
|
230
|
+
* longer), so the terminal would otherwise break them mid-word or
|
|
231
|
+
* mid-hash. This wraps them at word boundaries (over-long tokens
|
|
232
|
+
* hard-split at the width) — only when the emitter knows the width;
|
|
233
|
+
* piped/CI stderr keeps raw single lines, the machine-parseable form. */
|
|
234
|
+
export function wrapLinesForTerminal(text: string, columns: number): string {
|
|
235
|
+
if (!Number.isSafeInteger(columns) || columns < 8) return text;
|
|
236
|
+
const wrapped: string[] = [];
|
|
237
|
+
for (const line of text.split("\n")) {
|
|
238
|
+
if (line.length <= columns) {
|
|
239
|
+
wrapped.push(line);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
let current = "";
|
|
243
|
+
for (const token of line.split(" ")) {
|
|
244
|
+
// A token longer than the width hard-splits at the width (paths and
|
|
245
|
+
// SPKI pins have no spaces to break on); the remainder re-enters the
|
|
246
|
+
// same greedy fill so following words still pack.
|
|
247
|
+
const pieces: string[] = [];
|
|
248
|
+
let rest = token;
|
|
249
|
+
while (rest.length > columns) {
|
|
250
|
+
pieces.push(rest.slice(0, columns));
|
|
251
|
+
rest = rest.slice(columns);
|
|
252
|
+
}
|
|
253
|
+
pieces.push(rest);
|
|
254
|
+
for (const piece of pieces) {
|
|
255
|
+
if (current.length === 0) {
|
|
256
|
+
current = piece;
|
|
257
|
+
} else if (current.length + 1 + piece.length <= columns) {
|
|
258
|
+
current += ` ${piece}`;
|
|
259
|
+
} else {
|
|
260
|
+
wrapped.push(current);
|
|
261
|
+
current = piece;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
wrapped.push(current);
|
|
266
|
+
}
|
|
267
|
+
return wrapped.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
225
270
|
function executeVersionCommand(args: readonly string[]): CliCommandResult {
|
|
226
271
|
if (args.length === 0) {
|
|
227
272
|
return { exitCode: 0, stdout: renderVersion(), stderr: "" };
|
|
@@ -369,10 +414,34 @@ export function runCli(
|
|
|
369
414
|
}
|
|
370
415
|
|
|
371
416
|
if (command === "list") {
|
|
372
|
-
|
|
417
|
+
// Streaming delivery (ukp_list W2 / ADR 0026 rule 3): line-by-line
|
|
418
|
+
// through the same stdout writer every other print uses (console.log
|
|
419
|
+
// per line ≡ the single print's trailing-newline form, so piped bytes
|
|
420
|
+
// match the returned-text contract); progress frames only on an
|
|
421
|
+
// interactive stderr, so piped runs stay progress-silent. The stderr
|
|
422
|
+
// footnotes wrap at the terminal width there too — piped stderr keeps
|
|
423
|
+
// the raw single lines (wrapLinesForTerminal's contract).
|
|
424
|
+
const stderrColumns = process.stderr.columns ?? process.stdout.columns;
|
|
425
|
+
const interactivelyWrap = (result: CliCommandResult): CliCommandResult =>
|
|
426
|
+
process.stderr.isTTY === true && typeof stderrColumns === "number" && result.stderr.length > 0
|
|
427
|
+
? { ...result, stderr: wrapLinesForTerminal(result.stderr, stderrColumns) }
|
|
428
|
+
: result;
|
|
429
|
+
// Conditionality preserved (the W2 sync seam): an all-local listing
|
|
430
|
+
// stays a synchronous value wrapped synchronously; only the remote
|
|
431
|
+
// branch's promise goes through .then.
|
|
432
|
+
const result = executeListCommand(args.slice(1), {
|
|
373
433
|
currentDirectory,
|
|
374
434
|
registryPath,
|
|
375
|
-
|
|
435
|
+
...(process.stderr.isTTY === true
|
|
436
|
+
? { emitProgress: (frame: string) => process.stderr.write(frame) }
|
|
437
|
+
: {}),
|
|
438
|
+
emitStdout: (line: string) => stdout(line),
|
|
439
|
+
});
|
|
440
|
+
return writeCommandResultMaybeAsync(
|
|
441
|
+
result instanceof Promise ? result.then(interactivelyWrap) : interactivelyWrap(result),
|
|
442
|
+
stdout,
|
|
443
|
+
stderr,
|
|
444
|
+
);
|
|
376
445
|
}
|
|
377
446
|
|
|
378
447
|
if (command === "nav") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
UKP propose quickstart
|
|
2
2
|
|
|
3
|
-
Goal: submit a change proposal to a Service endpoint as an idempotent, adjudicable document
|
|
3
|
+
Goal: submit a change proposal to a Service endpoint as an idempotent, adjudicable document - not a file write.
|
|
4
4
|
|
|
5
5
|
Model:
|
|
6
6
|
- A proposal is a pending change the Service owner will adjudicate; the submitter proposes, the owner decides.
|
|
@@ -3,13 +3,13 @@ UKP Remote quickstart
|
|
|
3
3
|
Goal: expose registered Services from one machine and consume them from another with the same endpoint commands: search, read, nav, rg, and propose when declared.
|
|
4
4
|
|
|
5
5
|
Current model:
|
|
6
|
-
- The ssh:// path needs nothing running on the host: each invocation wakes a loopback door over the SSH connection (it serves the host Registry fresh and self-reaps when idle). Host prerequisites: the host is ssh reachable and has ukp installed the usual way (npm i -g or bun add -g
|
|
6
|
+
- The ssh:// path needs nothing running on the host: each invocation wakes a loopback door over the SSH connection (it serves the host Registry fresh and self-reaps when idle). Host prerequisites: the host is ssh reachable and has ukp installed the usual way (npm i -g or bun add -g - the wake finds the standard locations).
|
|
7
7
|
- The https path (LAN, public IP, or behind a reverse proxy) uses a resident door: run ukp serve under your process manager. Arbitrary consumers have no SSH credentials, so the listener itself is the trust boundary (TLS + bearer token).
|
|
8
8
|
- Prefer host door mode: run ukp serve without --endpoint to expose every local endpoint behind one port.
|
|
9
9
|
- The door reads the host Registry fresh on each request, so newly registered local endpoints appear without restarting the door.
|
|
10
|
-
- Declared endpoint names come from the host Service Manifest or discovery document; your registry binds each one under a local handle
|
|
10
|
+
- Declared endpoint names come from the host Service Manifest or discovery document; your registry binds each one under a local handle - the declared name by default, or --name <handle> when that name is already taken (two hosts both declaring 'notes' become your 'notes' and 'ali-notes'). --endpoint during register stays an expected-name assertion on the declared name.
|
|
11
11
|
|
|
12
|
-
Owner path over SSH (zero ceremony
|
|
12
|
+
Owner path over SSH (zero ceremony - nothing to start on the host):
|
|
13
13
|
1. Prerequisites (once): the host accepts your SSH key and has ukp installed the usual way
|
|
14
14
|
ssh <host> ukp --help
|
|
15
15
|
|
|
@@ -54,9 +54,9 @@ Name and identity:
|
|
|
54
54
|
- ssh:// urls take no port: registration rejects one (the woken door listens on a client-chosen loopback port; use an ssh config Host alias for a non-standard sshd port).
|
|
55
55
|
|
|
56
56
|
Operational note:
|
|
57
|
-
- ssh:// hosts: nothing to manage
|
|
58
|
-
- https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class)
|
|
59
|
-
- The deployment handbook is where those recipes live: it ships with this package (docs/remote-deployment.md) and online (https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md)
|
|
57
|
+
- ssh:// hosts: nothing to manage - ukp wakes and reaps doors itself (on Windows, enabling the built-in OpenSSH Server feature once is the low-burden path). SSH lockdown (login-shell restriction, why a forced authorized_keys command= does not compose) and nvm-style install layouts are deployment-handbook material.
|
|
58
|
+
- https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) - prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door; on Windows, ukp serve --print-task prints the resident-door artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) for you to review and apply yourself - the token stays yours to paste, never printed.
|
|
59
|
+
- The deployment handbook is where those recipes live: it ships with this package (docs/remote-deployment.md) and online (https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) - Windows resident door (certificate, start script, Task Scheduler), systemd socket-activation units, Caddy, Tailscale, hardening notes.
|
|
60
60
|
- serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
|
|
61
61
|
|
|
62
62
|
Remember:
|
|
@@ -25,7 +25,7 @@ Fast path for a simple knowledge folder:
|
|
|
25
25
|
By default add registers the collection under the folder's full path as
|
|
26
26
|
its name; the short name comes from this rename, never from add itself.
|
|
27
27
|
rename matches the exact current name only, so re-check collection list
|
|
28
|
-
before renaming
|
|
28
|
+
before renaming - the short name you had in mind may already belong to an
|
|
29
29
|
existing collection.
|
|
30
30
|
4. Update the index (qmd update, qmd status)
|
|
31
31
|
5. Verify the provider before returning to UKP (qmd search / qmd get)
|
|
@@ -36,6 +36,22 @@ export interface InventoryCommandContext {
|
|
|
36
36
|
resolveProvider?: ProviderResolver;
|
|
37
37
|
/** Test injection point for the ssh binary behind ssh:// transports. */
|
|
38
38
|
sshCommand?: readonly string[];
|
|
39
|
+
/** Streaming seam (ukp_list W2 / ADR 0026 rule 3): when present, the
|
|
40
|
+
* remote-mixed branch delivers stdout line-by-line through it — the header
|
|
41
|
+
* and every local row flush synchronously before any network work starts,
|
|
42
|
+
* remote rows append in registry order as they resolve — and the returned
|
|
43
|
+
* result carries an empty stdout (the output already went out on this
|
|
44
|
+
* channel; writeCommandResult's falsy check skips the final print).
|
|
45
|
+
* Absent, the full text returns in result.stdout unchanged. The all-local
|
|
46
|
+
* path never streams: it stays synchronous and byte-identical either
|
|
47
|
+
* way. */
|
|
48
|
+
emitStdout?: (line: string) => void;
|
|
49
|
+
/** Progress seam (ukp_list W2 / ADR 0026 rule 3): self-contained stderr
|
|
50
|
+
* frames for the remote fetch — the first frame is plain text, updates
|
|
51
|
+
* carry a leading \r, the final frame clears the line. The wiring layer
|
|
52
|
+
* injects it only when stderr is a TTY, so piped runs stay silent and
|
|
53
|
+
* stdout never gains a control character. */
|
|
54
|
+
emitProgress?: (frame: string) => void;
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export interface InventoryCommandResult {
|
|
@@ -52,13 +68,13 @@ export const REGISTER_SPEC: UkpCommandSpec = {
|
|
|
52
68
|
summary: "register a Service endpoint",
|
|
53
69
|
group: "registry",
|
|
54
70
|
description:
|
|
55
|
-
"Register the current Service folder, a remote ukp-serve endpoint via --url, or
|
|
71
|
+
"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
72
|
usage: "[options]",
|
|
57
73
|
strictArguments: true,
|
|
58
74
|
options: [
|
|
59
|
-
{ flags: "--url <url>", help: "register a remote ukp-serve endpoint (https://host[:port], ssh://host[/endpoint]
|
|
75
|
+
{ 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
76
|
{ 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
|
|
77
|
+
{ 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" },
|
|
62
78
|
{ 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" },
|
|
63
79
|
{ 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" },
|
|
64
80
|
],
|
|
@@ -75,7 +91,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
|
|
|
75
91
|
" With --url, the declared name and instance identity come from the",
|
|
76
92
|
" remote discovery document (/.well-known/ukp.json) and are pinned",
|
|
77
93
|
" TOFU-style as provenance; your registry binds them under a local",
|
|
78
|
-
" handle
|
|
94
|
+
" handle - the declared name by default, or --name <handle> when that",
|
|
79
95
|
" name is already taken. Use --endpoint <name> as an expected-name",
|
|
80
96
|
" assertion on the declared name; bearer-token services read",
|
|
81
97
|
" UKP_ENDPOINT_<NAME>_TOKEN (keyed by the handle) at call time.",
|
|
@@ -85,7 +101,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
|
|
|
85
101
|
" and imports every endpoint behind it (a door url may also name one",
|
|
86
102
|
" endpoint: ssh://ali/notes). Import is explicit and idempotent:",
|
|
87
103
|
" re-running refreshes TOFU pins and tokens. Name conflicts are skipped",
|
|
88
|
-
" with a visible reason (never silently renamed)
|
|
104
|
+
" with a visible reason (never silently renamed) - re-register that one",
|
|
89
105
|
" endpoint with --name to land it under another handle; --select narrows",
|
|
90
106
|
" the import; door growth shows in 'ukp list' as drift notes until",
|
|
91
107
|
" imported.",
|
|
@@ -126,7 +142,23 @@ const DEFAULT_CAPABILITIES = (Object.keys(FILE_NATIVE_CAPABILITIES) as Array<key
|
|
|
126
142
|
.filter((name) => FILE_NATIVE_CAPABILITIES[name].derived)
|
|
127
143
|
.sort();
|
|
128
144
|
|
|
129
|
-
|
|
145
|
+
/** One list row as structured cells — column geometry is decided once, in
|
|
146
|
+
* renderListOutput, from every row's actual widths (raw tab stops made the
|
|
147
|
+
* columns land on a different multiple-of-8 per row once names, paths and
|
|
148
|
+
* urls of very different widths mixed). */
|
|
149
|
+
interface InventoryListRow {
|
|
150
|
+
/** Endpoint handle; a remote handle that differs from its declared name
|
|
151
|
+
* carries the declaration inline (`name (declares X)`). */
|
|
152
|
+
name: string;
|
|
153
|
+
/** Local folder path or remote binding url. */
|
|
154
|
+
location: string;
|
|
155
|
+
/** Comma-separated declared extras, `-` for none, `(unavailable)` on
|
|
156
|
+
* degraded rows. */
|
|
157
|
+
capabilities: string;
|
|
158
|
+
warning?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderLocalListRow(endpoint: { name: string; path: string }): InventoryListRow {
|
|
130
162
|
let service;
|
|
131
163
|
try {
|
|
132
164
|
service = loadManifest(endpoint.path);
|
|
@@ -135,14 +167,16 @@ function renderLocalListRow(endpoint: { name: string; path: string }): { line: s
|
|
|
135
167
|
// when the underlying ManifestError carries a full zod schema dump.
|
|
136
168
|
const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
137
169
|
return {
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
name: endpoint.name,
|
|
171
|
+
location: endpoint.path,
|
|
172
|
+
capabilities: "(unavailable)",
|
|
173
|
+
warning: `endpoint '${endpoint.name}' declared capabilities unavailable: ${headline}`,
|
|
140
174
|
};
|
|
141
175
|
}
|
|
142
176
|
const extras = Object.keys(service.manifest.capabilities)
|
|
143
177
|
.filter((name) => !(isFileNativeCapability(name) && FILE_NATIVE_CAPABILITIES[name].derived))
|
|
144
178
|
.sort();
|
|
145
|
-
return {
|
|
179
|
+
return { name: endpoint.name, location: endpoint.path, capabilities: extras.length > 0 ? extras.join(",") : "-" };
|
|
146
180
|
}
|
|
147
181
|
|
|
148
182
|
export function executeRegisterCommand(
|
|
@@ -351,6 +385,7 @@ async function executeRegisterRemote(
|
|
|
351
385
|
const wellKnown = await fetchRegistrationDocument(transport.base, {
|
|
352
386
|
...(token !== undefined ? { token } : {}),
|
|
353
387
|
...(pinSelfSigned && tlsProbe !== undefined ? { tlsAnchor: { ca: tlsProbe.certPem } } : {}),
|
|
388
|
+
displayUrl: parts.origin,
|
|
354
389
|
});
|
|
355
390
|
|
|
356
391
|
if (wellKnown.kind === "door") {
|
|
@@ -520,22 +555,97 @@ export function executeListCommand(
|
|
|
520
555
|
// byte-identical and synchronous. One transport pool serves the whole
|
|
521
556
|
// invocation (O-5): same-origin door bindings — and their drift check —
|
|
522
557
|
// share a single ssh tunnel.
|
|
558
|
+
//
|
|
559
|
+
// Fan-out + streaming (ukp_list W1/W2 / ADR 0026 rules 1 and 3): local
|
|
560
|
+
// rows flush first — they are registry data in memory, so first output
|
|
561
|
+
// needs no network at all — then every remote fetch and the drift check
|
|
562
|
+
// start together (wall clock = max over origins, not the sum;
|
|
563
|
+
// same-origin acquires converge on the pool's single open promise), and
|
|
564
|
+
// remote rows append in registry order as they resolve, never rewriting
|
|
565
|
+
// an already-emitted line. Row order is W2's one agent-visible change:
|
|
566
|
+
// locals first, then remotes, each group in registry (name-sorted)
|
|
567
|
+
// order.
|
|
523
568
|
return (async () => {
|
|
524
569
|
const pool = createTransportPool({
|
|
525
570
|
registryPath: context.registryPath,
|
|
526
571
|
...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
|
|
527
572
|
});
|
|
528
573
|
try {
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
574
|
+
const localRows = endpoints
|
|
575
|
+
.filter((endpoint) => !isRemoteBinding(endpoint))
|
|
576
|
+
.map((endpoint) => renderLocalListRow({ name: endpoint.name, path: endpoint.path! }));
|
|
577
|
+
// Column geometry is decided once, before the first flush, from
|
|
578
|
+
// every row's registry-known leading cells (ADR 0026 rule 4): only
|
|
579
|
+
// the last cell depends on the network, so streaming never shifts a
|
|
580
|
+
// column width.
|
|
581
|
+
const geometry = listRowGeometry([
|
|
582
|
+
...localRows,
|
|
583
|
+
...remotes.map((endpoint) => ({ name: remoteRowName(endpoint), location: endpoint.url! })),
|
|
584
|
+
]);
|
|
585
|
+
const lines: string[] = [];
|
|
586
|
+
// The progress line and the streamed rows share one terminal: a
|
|
587
|
+
// frame never ends with \n, so the cursor parks at its end — the
|
|
588
|
+
// next row would print BESIDE the frame and the following \r would
|
|
589
|
+
// overwrite the row's head (Windows Terminal dogfood finding). The
|
|
590
|
+
// interlock: every row flush clears the frame first, owns its line,
|
|
591
|
+
// and lets the next drawProgress redraw on the fresh bottom row.
|
|
592
|
+
// With no progress channel (piped runs) the clear is a no-op and
|
|
593
|
+
// the stream is untouched.
|
|
594
|
+
const progress = context.emitProgress;
|
|
595
|
+
let progressWidth = 0;
|
|
596
|
+
const drawProgress = (frame: string) => {
|
|
597
|
+
if (progress === undefined) return;
|
|
598
|
+
progress(frame);
|
|
599
|
+
progressWidth = frame.length;
|
|
600
|
+
};
|
|
601
|
+
const clearProgress = () => {
|
|
602
|
+
if (progress === undefined || progressWidth === 0) return;
|
|
603
|
+
progress(`\r${" ".repeat(progressWidth)}\r`);
|
|
604
|
+
progressWidth = 0;
|
|
605
|
+
};
|
|
606
|
+
const flush = (line: string) => {
|
|
607
|
+
clearProgress();
|
|
608
|
+
lines.push(line);
|
|
609
|
+
context.emitStdout?.(line);
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
flush(listHeaderLine());
|
|
613
|
+
for (const row of localRows) flush(renderListLine(row, geometry));
|
|
614
|
+
|
|
615
|
+
// Everything network-facing starts only after the local flush — and
|
|
616
|
+
// the progress line goes out first, so the user never watches a
|
|
617
|
+
// spinner-less wait. Frames must stay ASCII/narrow and each update
|
|
618
|
+
// at least as wide as its predecessor: the clear blanks
|
|
619
|
+
// progressWidth columns, so a wider glyph than code units (or a
|
|
620
|
+
// shorter update) would leave stale tail cells on a real terminal.
|
|
621
|
+
drawProgress(`fetching ${remotes.length} remote endpoint(s)...`);
|
|
622
|
+
const remoteRows = remotes.map((endpoint) => renderRemoteListRow(endpoint, pool));
|
|
623
|
+
const drift = collectDoorDriftNotes(endpoints, pool);
|
|
624
|
+
|
|
625
|
+
const warnings = localRows.flatMap((row) => (row.warning !== undefined ? [row.warning] : []));
|
|
626
|
+
let done = 0;
|
|
627
|
+
// Head-of-line emission: the fetches already run concurrently; rows
|
|
628
|
+
// print in registry order as their predecessors resolve. Each flush
|
|
629
|
+
// cleared the frame; the update redraws it on the fresh bottom row.
|
|
630
|
+
for (const row of remoteRows) {
|
|
631
|
+
const resolved = await row;
|
|
632
|
+
flush(renderListLine(resolved, geometry));
|
|
633
|
+
if (resolved.warning !== undefined) warnings.push(resolved.warning);
|
|
634
|
+
done += 1;
|
|
635
|
+
if (done < remotes.length) drawProgress(`\rfetching remote endpoint(s): ${done}/${remotes.length}`);
|
|
536
636
|
}
|
|
537
|
-
|
|
538
|
-
|
|
637
|
+
// Cleared before the drift await (the last row's flush already
|
|
638
|
+
// blanked the frame): every row is on screen, so the table reads
|
|
639
|
+
// complete while the stderr footnotes gather — an https door's
|
|
640
|
+
// drift fetch may be the slowest leg and a spinner under a finished
|
|
641
|
+
// table would be noise.
|
|
642
|
+
clearProgress();
|
|
643
|
+
const stderrLines = [...warnings, ...collectSameInstanceNotes(endpoints), ...(await drift)];
|
|
644
|
+
return {
|
|
645
|
+
exitCode: 0,
|
|
646
|
+
stdout: context.emitStdout === undefined ? lines.join("\n") : "",
|
|
647
|
+
stderr: stderrLines.length > 0 ? `${stderrLines.join("\n")}\n` : "",
|
|
648
|
+
};
|
|
539
649
|
} finally {
|
|
540
650
|
pool.close();
|
|
541
651
|
}
|
|
@@ -552,14 +662,11 @@ export function executeListCommand(
|
|
|
552
662
|
}
|
|
553
663
|
|
|
554
664
|
function renderListOutput(
|
|
555
|
-
rows: ReadonlyArray<
|
|
665
|
+
rows: ReadonlyArray<InventoryListRow>,
|
|
556
666
|
extraNotes: readonly string[] = [],
|
|
557
667
|
): InventoryCommandResult {
|
|
558
668
|
const warnings = [...rows.flatMap((row) => (row.warning !== undefined ? [row.warning] : [])), ...extraNotes];
|
|
559
|
-
const stdout = [
|
|
560
|
-
`capabilities on every endpoint: ${DEFAULT_CAPABILITIES.join(", ")} (derived file-native); additional declared capabilities per endpoint:`,
|
|
561
|
-
...rows.map((row) => row.line),
|
|
562
|
-
].join("\n");
|
|
669
|
+
const stdout = [listHeaderLine(), ...rows.map((row) => renderListLine(row, listRowGeometry(rows)))].join("\n");
|
|
563
670
|
return {
|
|
564
671
|
exitCode: 0,
|
|
565
672
|
stdout,
|
|
@@ -567,20 +674,57 @@ function renderListOutput(
|
|
|
567
674
|
};
|
|
568
675
|
}
|
|
569
676
|
|
|
677
|
+
function listHeaderLine(): string {
|
|
678
|
+
// The parenthetical is plain-language by probe verdict (20260921-list-wording):
|
|
679
|
+
// blank readers parsed "(built-in, from the folder itself)" directly, while
|
|
680
|
+
// "(derived file-native)" read as undefined jargon.
|
|
681
|
+
return `capabilities on every endpoint: ${DEFAULT_CAPABILITIES.join(", ")} (built-in, from the folder itself); additional declared capabilities per endpoint:`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Space-padded columns: each cell pads to the widest same-column cell plus
|
|
685
|
+
* a two-space gutter (the guide topic list's idiom), last column unpadded so
|
|
686
|
+
* no line carries trailing whitespace. Tab-separated rows rendered ragged
|
|
687
|
+
* whenever name/location widths straddled the terminal's tab stops. The
|
|
688
|
+
* padded columns are registry-known strings (ADR 0026 rule 4): only the
|
|
689
|
+
* last, unpadded cell depends on the remote fetch — so geometry can be
|
|
690
|
+
* computed before the first flush and streaming never shifts a width. */
|
|
691
|
+
interface ListRowGeometry {
|
|
692
|
+
nameWidth: number;
|
|
693
|
+
locationWidth: number;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function listRowGeometry(rows: ReadonlyArray<Pick<InventoryListRow, "name" | "location">>): ListRowGeometry {
|
|
697
|
+
return {
|
|
698
|
+
nameWidth: Math.max(...rows.map((row) => row.name.length)),
|
|
699
|
+
locationWidth: Math.max(...rows.map((row) => row.location.length)),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function renderListLine(row: InventoryListRow, geometry: ListRowGeometry): string {
|
|
704
|
+
return `${row.name.padEnd(geometry.nameWidth)} ${row.location.padEnd(geometry.locationWidth)} ${row.capabilities}`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** The name cell of a remote row — registry-known without any network, so
|
|
708
|
+
* column geometry and the streamed local flush can lay out before any
|
|
709
|
+
* fetch resolves. A handle that differs from the declared name
|
|
710
|
+
* (ADR-REM-007) carries the declaration inline for audit. */
|
|
711
|
+
function remoteRowName(endpoint: RegistryBinding): string {
|
|
712
|
+
return endpoint.declared_name !== undefined && endpoint.declared_name !== endpoint.name
|
|
713
|
+
? `${endpoint.name} (declares ${endpoint.declared_name})`
|
|
714
|
+
: endpoint.name;
|
|
715
|
+
}
|
|
716
|
+
|
|
570
717
|
/** Remote row: capabilities from the discovery document; unreachable
|
|
571
718
|
* endpoints degrade to `(unavailable)` + one stderr warning — the same
|
|
572
719
|
* inventory semantics as an unreadable local manifest. Door-form bindings
|
|
573
720
|
* (`ssh://ali/notes`) fetch their per-endpoint document through the shared
|
|
574
|
-
* pool's tunnel.
|
|
575
|
-
* carries the declaration inline for audit. */
|
|
721
|
+
* pool's tunnel. */
|
|
576
722
|
async function renderRemoteListRow(
|
|
577
723
|
endpoint: RegistryBinding,
|
|
578
724
|
pool: RemoteTransportPool,
|
|
579
|
-
): Promise<
|
|
725
|
+
): Promise<InventoryListRow> {
|
|
580
726
|
const url = endpoint.url!;
|
|
581
|
-
const
|
|
582
|
-
? ` (declares ${endpoint.declared_name})`
|
|
583
|
-
: "";
|
|
727
|
+
const name = remoteRowName(endpoint);
|
|
584
728
|
try {
|
|
585
729
|
const transport = await pool.acquire(endpoint);
|
|
586
730
|
const discovery = await fetchDiscoveryDocument(endpoint, transport, resolveRemoteToken(endpoint));
|
|
@@ -589,12 +733,14 @@ async function renderRemoteListRow(
|
|
|
589
733
|
.map(([name]) => name)
|
|
590
734
|
.sort();
|
|
591
735
|
const warnings = discovery.warnings.length > 0 ? { warning: discovery.warnings.join("; ") } : {};
|
|
592
|
-
return {
|
|
736
|
+
return { name, location: url, capabilities: extras.length > 0 ? extras.join(",") : "-", ...warnings };
|
|
593
737
|
} catch (error) {
|
|
594
738
|
const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
595
739
|
return {
|
|
596
|
-
|
|
597
|
-
|
|
740
|
+
name,
|
|
741
|
+
location: url,
|
|
742
|
+
capabilities: "(unavailable)",
|
|
743
|
+
warning: `endpoint '${endpoint.name}' declared capabilities unavailable: ${headline}`,
|
|
598
744
|
};
|
|
599
745
|
}
|
|
600
746
|
}
|
|
@@ -616,7 +762,7 @@ function collectSameInstanceNotes(endpoints: readonly RegistryBinding[]): string
|
|
|
616
762
|
for (const [uid, handles] of byUid) {
|
|
617
763
|
if (handles.length > 1) {
|
|
618
764
|
notes.push(
|
|
619
|
-
`note: ${[...handles].sort().map((handle) => `'${handle}'`).join(" and ")} pin the same instance_uid ${uid}
|
|
765
|
+
`note: ${[...handles].sort().map((handle) => `'${handle}'`).join(" and ")} pin the same instance_uid ${uid} - one service under two handles`,
|
|
620
766
|
);
|
|
621
767
|
}
|
|
622
768
|
}
|
|
@@ -661,7 +807,7 @@ async function collectDoorDriftNotes(
|
|
|
661
807
|
url: origin,
|
|
662
808
|
...(anchor.tls_cert !== undefined ? { tls_cert: anchor.tls_cert, tls_pin: anchor.tls_pin } : {}),
|
|
663
809
|
});
|
|
664
|
-
const door = await fetchDoorDocument(transport.base, {});
|
|
810
|
+
const door = await fetchDoorDocument(transport.base, { displayUrl: transport.displayUrl });
|
|
665
811
|
const unimported = door.doc.endpoints
|
|
666
812
|
.map((endpoint) => endpoint.name)
|
|
667
813
|
.filter((name) => !group.imported.has(name));
|
|
@@ -674,12 +820,12 @@ async function collectDoorDriftNotes(
|
|
|
674
820
|
const taken = unimported.filter((name) => endpoints.some((binding) => binding.name === name));
|
|
675
821
|
if (free.length > 0) {
|
|
676
822
|
notes.push(
|
|
677
|
-
`door ${origin}: ${free.length} unimported endpoint(s): ${free.join(", ")}
|
|
823
|
+
`door ${origin}: ${free.length} unimported endpoint(s): ${free.join(", ")} - run 'ukp register --url ${origin}' to import`,
|
|
678
824
|
);
|
|
679
825
|
}
|
|
680
826
|
if (taken.length > 0) {
|
|
681
827
|
notes.push(
|
|
682
|
-
`door ${origin}: name(s) taken: ${taken.join(", ")}
|
|
828
|
+
`door ${origin}: name(s) taken: ${taken.join(", ")} - import under another handle: 'ukp register --url ${origin}/<name> --name <handle>'`,
|
|
683
829
|
);
|
|
684
830
|
}
|
|
685
831
|
} catch {
|
package/src/commands/propose.ts
CHANGED
|
@@ -73,7 +73,7 @@ export const PROPOSE_SPEC: UkpCommandSpec = {
|
|
|
73
73
|
options: [
|
|
74
74
|
{
|
|
75
75
|
flags: "--id <slug>",
|
|
76
|
-
help: "revision key: resubmitting the same id updates the same proposal (revision +1); 1-63 lowercase ASCII slug (enforced for explicit ids and the default alike); defaults to the --file basename, which then becomes the proposal's persistent id
|
|
76
|
+
help: "revision key: resubmitting the same id updates the same proposal (revision +1); 1-63 lowercase ASCII slug (enforced for explicit ids and the default alike); defaults to the --file basename, which then becomes the proposal's persistent id - renaming the file creates a new proposal",
|
|
77
77
|
},
|
|
78
78
|
{ flags: "--file <path>", help: "read the proposal content from a file (required)" },
|
|
79
79
|
{ flags: "--json", help: "emit the structured response envelope" },
|
package/src/commands/read.ts
CHANGED
|
@@ -96,18 +96,18 @@ export const READ_SPEC: UkpCommandSpec = {
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
flags: "--show-pin",
|
|
99
|
-
help: "emit the ukp-pin for the read resource on stderr (<!-- ukp-pin: sha256
|
|
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
100
|
},
|
|
101
101
|
{ flags: "--format <mode>", help: "output mode: 'json' emits a structured failure envelope (body still goes to stdout); default is human output" },
|
|
102
102
|
],
|
|
103
103
|
helpSuffix: [
|
|
104
104
|
"",
|
|
105
105
|
"Reference forms:",
|
|
106
|
-
" endpoint-local path
|
|
107
|
-
" ukp://<endpoint>/<rel-path>[#L<line>]
|
|
108
|
-
" docid[:line] / qmd://<reference>
|
|
106
|
+
" endpoint-local path - requires --endpoint <name>",
|
|
107
|
+
" ukp://<endpoint>/<rel-path>[#L<line>] - carries its own endpoint",
|
|
108
|
+
" docid[:line] / qmd://<reference> - provider-owned references;",
|
|
109
109
|
" require --endpoint <name> and are resolved by the Service's provider",
|
|
110
|
-
" absolute filesystem path
|
|
110
|
+
" absolute filesystem path - the owning endpoint is matched from the",
|
|
111
111
|
" Host Registry (mapping echoed on stderr)",
|
|
112
112
|
"",
|
|
113
113
|
].join("\n"),
|
|
@@ -481,7 +481,7 @@ async function executeRemoteRead(
|
|
|
481
481
|
exitCode: 2,
|
|
482
482
|
stdout: "",
|
|
483
483
|
stderr: renderReadUsageError(
|
|
484
|
-
"--show-pin needs whole-file content; drop the line window (--lines / #L)
|
|
484
|
+
"--show-pin needs whole-file content; drop the line window (--lines / #L) - the pin always covers the whole file",
|
|
485
485
|
),
|
|
486
486
|
};
|
|
487
487
|
}
|