@catheadowl/ukp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
- import { chmodSync, existsSync, mkdirSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { hostname, networkInterfaces } from "node:os";
4
4
  import { X509Certificate, createHash } from "node:crypto";
5
+ import { isIP } from "node:net";
6
+ import { acquireLock, releaseLock } from "../fslock.ts";
5
7
 
6
8
  /** TLS transport identity (ukp_remote W5' / D-079): the self-signed + TOFU
7
9
  * pinning path for serving over bare IPs without a public CA. Shared by the
@@ -28,60 +30,200 @@ export function certSanOf(cert: string | Buffer): string {
28
30
  export function selfSignedSanEntries(): string[] {
29
31
  const entries = new Set<string>(["IP:127.0.0.1", "IP:::1", "DNS:localhost"]);
30
32
  const host = hostname();
31
- if (/^[A-Za-z0-9.-]+$/.test(host) && !host.includes("_")) entries.add(`DNS:${host}`);
33
+ if (/^[A-Za-z0-9.-]+$/.test(host) && !host.includes("_")) entries.add(`DNS:${host.toLowerCase()}`);
32
34
  for (const addresses of Object.values(networkInterfaces())) {
33
35
  for (const address of addresses ?? []) {
34
- if (!address.internal) entries.add(`IP:${address.address}`);
36
+ if (!address.internal) entries.add(`IP:${address.address.toLowerCase()}`);
35
37
  }
36
38
  }
37
39
  return [...entries];
38
40
  }
39
41
 
42
+ const DNS_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
43
+
44
+ /** One `--tls-san <ip|dns>` value → its canonical openssl SAN form
45
+ * (`IP:<addr>` / `DNS:<name>`, lowercased — SAN IP/DNS matching is
46
+ * case-insensitive, so the whole SAN pipeline compares on folded keys),
47
+ * classifying by shape: a bare IP becomes an IP SAN, anything else must be a
48
+ * legal DNS name. The classification is what lets a cloud NAT/EIP host add
49
+ * its public address — it is on no NIC, so the automatic coverage above can
50
+ * never see it. Throws on values that are neither. */
51
+ export function normalizeTlsSanEntry(value: string): string {
52
+ const trimmed = value.trim();
53
+ if (trimmed.length === 0) throw new Error("--tls-san entry must not be empty");
54
+ if (isIP(trimmed) !== 0) return `IP:${trimmed.toLowerCase()}`;
55
+ if (DNS_NAME.test(trimmed) && trimmed.length <= 253) return `DNS:${trimmed.toLowerCase()}`;
56
+ throw new Error(`--tls-san entry '${value}' is neither an IP address nor a DNS name`);
57
+ }
58
+
59
+ /** Fully expanded lowercase IPv6 form (group-wise parseInt also strips
60
+ * leading zeros); `fd00::1` and `fd00:0:0:0:0:0:0:1` are the same SAN, and
61
+ * openssl re-renders a compressed literal expanded on some platforms, so
62
+ * spellings must not leak into comparisons. Input is isIP-validated. */
63
+ function expandIpv6(address: string): string {
64
+ const groupsOf = (part: string): string[] => {
65
+ if (part.length === 0) return [];
66
+ const groups: string[] = [];
67
+ for (const piece of part.split(":")) {
68
+ if (piece.includes(".")) {
69
+ // embedded IPv4 tail (::ffff:1.2.3.4) = two 16-bit groups
70
+ const [a = 0, b = 0, c = 0, d = 0] = piece.split(".").map(Number);
71
+ groups.push(((a << 8) | b).toString(16), ((c << 8) | d).toString(16));
72
+ } else {
73
+ groups.push(parseInt(piece, 16).toString(16));
74
+ }
75
+ }
76
+ return groups;
77
+ };
78
+ const halves = address.split("::");
79
+ if (halves.length === 1) return groupsOf(halves[0] ?? "").join(":");
80
+ if (halves.length !== 2) return address.toLowerCase();
81
+ const head = groupsOf(halves[0] ?? "");
82
+ const tail = groupsOf(halves[1] ?? "");
83
+ return [...head, ...Array.from({ length: 8 - head.length - tail.length }, () => "0"), ...tail].join(":");
84
+ }
85
+
86
+ /** Canonical comparison key for one `IP:x`/`DNS:y` SAN entry: uppercase
87
+ * type prefix, lowercase value, IPv6 expanded — the same shape openssl's
88
+ * addext wants, because re-signs emit inherited entries (canonical keys)
89
+ * back into the extension. Coverage decisions and deduplication compare
90
+ * keys, never spellings. */
91
+ function sanKey(entry: string): string {
92
+ const separator = entry.indexOf(":");
93
+ if (separator < 1) return entry.toUpperCase();
94
+ const type = entry.slice(0, separator).toUpperCase();
95
+ const value = entry.slice(separator + 1);
96
+ if (type === "IP") return `IP:${isIP(value) === 6 ? expandIpv6(value) : value.toLowerCase()}`;
97
+ return `${type}:${value.toLowerCase()}`;
98
+ }
99
+
100
+ /** The SAN entries a certificate actually carries, as canonical keys
101
+ * (`IP:x` with IPv6 expanded / `DNS:y`, case-folded — X509Certificate
102
+ * renders "IP Address:x", and openssl may spell an IPv6 literal differently
103
+ * than the requester did). */
104
+ export function certSanEntriesOf(cert: string | Buffer): Set<string> {
105
+ const raw = new X509Certificate(cert).subjectAltName ?? "";
106
+ const entries = new Set<string>();
107
+ for (const part of raw.split(",")) {
108
+ const entry = part.trim();
109
+ if (entry.startsWith("IP Address:")) entries.add(sanKey(`IP:${entry.slice("IP Address:".length)}`));
110
+ else if (entry.startsWith("DNS:")) entries.add(sanKey(entry));
111
+ }
112
+ return entries;
113
+ }
114
+
40
115
  export interface SelfSignedIdentity {
41
116
  certPath: string;
42
117
  keyPath: string;
43
- /** true when this call generated the pair; false when reusing the persisted one. */
44
- created: boolean;
118
+ /** What this call did to the identity: `generated` = fresh keypair (first
119
+ * `--tls`), `re-signed` = new certificate over the existing key (coverage
120
+ * grew — `--tls-san` added an entry the persisted certificate lacked; the
121
+ * pin is unchanged and pinned clients re-anchor transparently), `persisted`
122
+ * = both files reused untouched. */
123
+ source: "generated" | "re-signed" | "persisted";
45
124
  }
46
125
 
47
126
  /** Idempotent self-signed identity for `ukp serve --tls`: persisted under
48
127
  * `<serviceFolder>/.ukp/tls/` (machine-local, gitignored — the instance-uid
49
128
  * posture). openssl signs it because node:crypto cannot issue X.509; the
50
129
  * identity is the keypair, so losing the key loses the identity (clients
51
- * re-register). `opensslCommand` is a test injection point. */
130
+ * re-register). `extraSanEntries` (the `--tls-san` flag, already normalized
131
+ * `IP:x`/`DNS:y`) merges into the SAN coverage; when a persisted certificate
132
+ * lacks requested entries it is re-signed over the SAME key — the SPKI pin
133
+ * survives, which is exactly the renewal semantics pinned clients already
134
+ * handle. Coverage only ever grows: the re-sign unions everything the
135
+ * certificate already carried with automatic coverage and the new entries,
136
+ * and dropping a flag entry never re-signs. The read-decide-write section
137
+ * holds the write-discipline sibling lock (`.lock` in the tls dir) so two
138
+ * concurrent serve starts cannot interleave openssl writes.
139
+ * `opensslCommand` is a test injection point. */
52
140
  export function ensureSelfSignedTlsFiles(
53
141
  tlsDir: string,
54
- options: { opensslCommand?: readonly string[] } = {},
142
+ options: { opensslCommand?: readonly string[]; extraSanEntries?: readonly string[] } = {},
55
143
  ): SelfSignedIdentity {
56
144
  const certPath = join(tlsDir, "cert.pem");
57
145
  const keyPath = join(tlsDir, "key.pem");
58
- if (existsSync(certPath) && existsSync(keyPath)) {
59
- return { certPath, keyPath, created: false };
60
- }
146
+ const extra = options.extraSanEntries ?? [];
61
147
  mkdirSync(tlsDir, { recursive: true, mode: 0o700 });
62
- const proc = Bun.spawnSync(
63
- [
64
- ...(options.opensslCommand ?? ["openssl"]),
65
- "req", "-x509", "-newkey", "rsa:2048",
66
- "-keyout", keyPath,
67
- "-out", certPath,
68
- "-days", "3650",
69
- "-nodes",
70
- "-subj", "/CN=ukp serve self-signed",
71
- "-addext", `subjectAltName=${selfSignedSanEntries().join(",")}`,
72
- ],
73
- { stdout: "ignore", stderr: "pipe" },
74
- );
75
- if (proc.exitCode !== 0 || !existsSync(certPath) || !existsSync(keyPath)) {
148
+ const lockPath = join(tlsDir, ".lock");
149
+ const descriptor = acquireLock(lockPath);
150
+ try {
151
+ if (existsSync(certPath) && existsSync(keyPath)) {
152
+ let carried: string[];
153
+ try {
154
+ carried = [...certSanEntriesOf(readFileSync(certPath, "utf8"))];
155
+ } catch (error) {
156
+ throw new Error(
157
+ `the persisted self-signed certificate is unreadable (${certPath}): ${error instanceof Error ? error.message : String(error)}`,
158
+ );
159
+ }
160
+ const requestedButMissing = extra.filter((entry) => !carried.includes(sanKey(entry)));
161
+ if (requestedButMissing.length === 0) return { certPath, keyPath, source: "persisted" };
162
+ const san = mergedSanEntries(extra, carried);
163
+ runOpenssl(
164
+ [
165
+ ...(options.opensslCommand ?? ["openssl"]),
166
+ "req", "-x509",
167
+ "-key", keyPath,
168
+ "-out", certPath,
169
+ "-days", "3650",
170
+ "-subj", "/CN=ukp serve self-signed",
171
+ "-addext", `subjectAltName=${san.join(",")}`,
172
+ ],
173
+ [certPath],
174
+ );
175
+ return { certPath, keyPath, source: "re-signed" };
176
+ }
177
+ const san = mergedSanEntries(extra);
178
+ runOpenssl(
179
+ [
180
+ ...(options.opensslCommand ?? ["openssl"]),
181
+ "req", "-x509", "-newkey", "rsa:2048",
182
+ "-keyout", keyPath,
183
+ "-out", certPath,
184
+ "-days", "3650",
185
+ "-nodes",
186
+ "-subj", "/CN=ukp serve self-signed",
187
+ "-addext", `subjectAltName=${san.join(",")}`,
188
+ ],
189
+ [certPath, keyPath],
190
+ );
191
+ try {
192
+ chmodSync(keyPath, 0o600);
193
+ } catch {
194
+ // best-effort on platforms without POSIX modes
195
+ }
196
+ return { certPath, keyPath, source: "generated" };
197
+ } finally {
198
+ releaseLock(lockPath, descriptor);
199
+ }
200
+ }
201
+
202
+ /** The SAN list handed to openssl's subjectAltName extension: automatic
203
+ * coverage, then the requested extras, then (on re-sign) entries the
204
+ * persisted certificate already carried — deduplicated by canonical key so
205
+ * one spelling of each entry survives. */
206
+ function mergedSanEntries(extra: readonly string[], carried: readonly string[] = []): string[] {
207
+ const seen = new Set<string>();
208
+ const entries: string[] = [];
209
+ for (const entry of [...selfSignedSanEntries(), ...extra, ...carried]) {
210
+ const key = sanKey(entry);
211
+ if (!seen.has(key)) {
212
+ seen.add(key);
213
+ entries.push(entry);
214
+ }
215
+ }
216
+ return entries;
217
+ }
218
+
219
+ /** Run one openssl signing command; every declared output must appear. */
220
+ function runOpenssl(args: readonly string[], outputs: readonly string[]): void {
221
+ const proc = Bun.spawnSync([...args], { stdout: "ignore", stderr: "pipe" });
222
+ const failed = proc.exitCode !== 0 || outputs.some((path) => !existsSync(path));
223
+ if (failed) {
76
224
  const stderr = new TextDecoder().decode(proc.stderr).trim();
77
225
  throw new Error(
78
226
  `unable to generate a self-signed TLS identity with openssl (exit ${proc.exitCode}): ${stderr || "is openssl installed and on PATH? 'ukp serve --tls' self-signs through the local openssl"}`,
79
227
  );
80
228
  }
81
- try {
82
- chmodSync(keyPath, 0o600);
83
- } catch {
84
- // best-effort on platforms without POSIX modes
85
- }
86
- return { certPath, keyPath, created: true };
87
229
  }
package/src/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { executeSearchCommand, SEARCH_SPEC } from "./commands/search.ts";
24
24
  export { renderSearchHelp } from "./commands/search.ts";
25
25
  export { renderDiagnoseHelp } from "./commands/diagnose.ts";
26
26
  export { renderReadHelp } from "./commands/read.ts";
27
- export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderProposeGuide } from "./commands/guide.ts";
27
+ export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
28
28
  export { renderInitHelp, renderInitServiceHelp } from "./commands/init.ts";
29
29
  export { renderInspectHelp } from "./commands/inspect.ts";
30
30
  export { renderUpdateHelp } from "./commands/update.ts";
@@ -29,8 +29,20 @@ Model:
29
29
  4. Dangling defaults warn instead of failing silently
30
30
  A default_endpoints entry not registered yet is reported as a warning by inspect, and commands still run on the resolvable ones.
31
31
 
32
+ 5. Pin your own ukp:// references (optional; powers rename recovery)
33
+ A pin is a same-line HTML comment carrying the target's content hash:
34
+ see ukp://endpoint-a/docs/foo.md <!-- ukp-pin: sha256-<64 lowercase hex> -->
35
+ Emit one with 'ukp read --show-pin <same reference>'; the pin covers the
36
+ whole file (line endings normalized, so a pin verifies on any platform).
37
+ When 'ukp read' recovers a moved target, the pin is verified against the
38
+ recovered file; a mismatch refuses. Boundary: without git history only
39
+ same-name moves are recoverable (search re-anchor); a true rename in a
40
+ non-git folder stays resource-missing.
41
+
32
42
  Remember:
33
43
  - Client path consumes Services; provider path (ukp guide service) creates them.
34
44
  - Client Config lists names, not paths or provider details.
35
45
  - Explicit selectors always beat Client Config defaults.
36
46
  - Register does not edit Client Config, and Client Config does not register endpoints.
47
+ - Search run artifacts live outside your folders: %LOCALAPPDATA%\ukp\artifacts
48
+ on Windows, ~/.cache/ukp/artifacts (or $XDG_CACHE_HOME) elsewhere.
@@ -0,0 +1,64 @@
1
+ UKP Remote quickstart
2
+
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
+
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 — the wake finds the standard locations).
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
+ - Prefer host door mode: run ukp serve without --endpoint to expose every local endpoint behind one port.
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 — 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
+
12
+ Owner path over SSH (zero ceremony — nothing to start on the host):
13
+ 1. Prerequisites (once): the host accepts your SSH key and has ukp installed the usual way
14
+ ssh <host> ukp --help
15
+
16
+ 2. On the consuming machine, import the door (the first call wakes it)
17
+ ukp register --url ssh://<host>
18
+
19
+ 3. Use the imported endpoints by name; later calls reuse the warmed connection
20
+ ukp list
21
+ ukp read --endpoint <name> notes/x.md
22
+ ukp rg "keyword" --endpoint <name>
23
+
24
+ LAN HTTPS path:
25
+ 1. On the host machine, run a tokened TLS door (keep it under your process manager)
26
+ UKP_SERVE_TOKEN=<token> ukp serve --host 0.0.0.0 --tls
27
+ NAT/EIP cloud host (public IP on no NIC)? Name the public IP explicitly:
28
+ UKP_SERVE_TOKEN=<token> ukp serve --host 0.0.0.0 --tls --tls-san <public-ip>
29
+ Linux hosts can go resident-free too: systemd socket activation holds the
30
+ port and spawns the door per use (ukp serve --systemd-socket, recipe in
31
+ the deployment handbook)
32
+
33
+ 2. On the consuming machine, import either the whole door or one endpoint
34
+ ukp register --url https://<ip>:8570 --token <token>
35
+ ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
36
+
37
+ 3. Use the imported endpoint normally
38
+ ukp nav --endpoint <name>
39
+ ukp read --endpoint <name> notes/x.md
40
+ ukp rg "keyword" --endpoint <name>
41
+
42
+ Single endpoint mode:
43
+ Use this when you deliberately want to expose only one endpoint instead of the whole host Registry.
44
+ UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --tls
45
+ ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
46
+
47
+ Name and identity:
48
+ - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken).
49
+ - --endpoint <name> on register asserts the expected name; if discovery declares a different name, registration fails.
50
+ - A host door can import everything, --select a comma-separated subset, or --endpoint exactly one endpoint.
51
+ - Self-signed HTTPS certificates are pinned on first registration; compare the pinned fingerprint with the serve banner on untrusted networks.
52
+ - --tls names the host's interface IPs only. A NAT/EIP host's public IP needs --tls-san <public-ip>; without it registration fails on certificate name mismatch. Adding it later re-signs the persisted cert over the same key, so existing pins survive.
53
+ - Non-loopback plain http is refused. Use https, ssh://, or loopback http for local testing.
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
+
56
+ Operational note:
57
+ - ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself; 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. The CLI does not install or autostart the door.
59
+ - serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
60
+
61
+ Remember:
62
+ - Local registration makes a folder addressable on the host.
63
+ - Remote registration imports host-discovered endpoint bindings into the consumer Registry.
64
+ - Client Config still stores endpoint names only; after remote import, guide client works the same way as local endpoints.
@@ -18,14 +18,19 @@ Fast path for a simple knowledge folder:
18
18
  1. Initialize QMD (qmd init)
19
19
  2. Add the searchable collections (qmd collection add)
20
20
  For a pure knowledge base, add the folder root; otherwise add the folder(s)
21
- whose content should be searchable.
21
+ whose content should be searchable. Check what already exists first
22
+ (qmd collection list): re-adding an already-indexed folder creates a
23
+ duplicate collection, and add does not warn about it.
22
24
  3. Use a stable short collection name (qmd collection rename)
23
- QMD names a collection from the indexed folder; rename it to a short
24
- stable name so provider-native artifact URIs stay clean.
25
+ By default add registers the collection under the folder's full path as
26
+ its name; the short name comes from this rename, never from add itself.
27
+ rename matches the exact current name only, so re-check collection list
28
+ before renaming — the short name you had in mind may already belong to an
29
+ existing collection.
25
30
  4. Update the index (qmd update, qmd status)
26
31
  5. Verify the provider before returning to UKP (qmd search / qmd get)
27
32
  If a search returns matches, the provider is ready; return to
28
- 'ukp guide service' step 4.
33
+ 'ukp guide service' step 7.
29
34
  A read smoke ('qmd get <reference> --no-line-numbers') is required for
30
35
  observe mode (external or nested collection root): it confirms the provider
31
36
  can resolve the provider references that ukp read will delegate to it
@@ -1,6 +1,6 @@
1
1
  UKP Service quickstart
2
2
 
3
- Goal: make a folder a named Knowledge Service, register it, inspect the route, search it, and read an endpoint-scoped reference.
3
+ Goal: make a folder a named Knowledge Service, register it, inspect the route, and use the provider-free read/nav/rg baseline. Add QMD only when you want indexed search/update.
4
4
  Run the setup commands from the Service folder root.
5
5
 
6
6
  Fast path:
@@ -8,32 +8,34 @@ Fast path:
8
8
  ukp init service --name your-endpoint-name
9
9
  Optional: add --description "Short boundary note"
10
10
 
11
- 2. Make content searchable through the provider
12
- ukp guide service qmd
13
-
14
- 3. Validate and register the Service
11
+ 2. Validate and register the Service
15
12
  ukp diagnose
16
13
  ukp register
17
14
  ukp list
18
15
 
19
- 4. Inspect, search, then read
16
+ 3. Inspect, navigate, grep, then read
20
17
  ukp inspect --endpoint your-endpoint-name
18
+ ukp nav --endpoint your-endpoint-name --depth 2
19
+ ukp rg "keyword" --endpoint your-endpoint-name --limit 3
20
+ ukp read --endpoint your-endpoint-name docs/example.md
21
+
22
+ 4. Optional: add indexed search through the provider
23
+ ukp guide service qmd
21
24
  ukp search "keyword" --endpoint your-endpoint-name --limit 3
22
- ukp read --endpoint your-endpoint-name <reference>
23
25
 
24
26
  Key boundary:
25
27
  - init/register makes the folder addressable as a Service.
26
- - provider setup makes the Service's content searchable.
27
- - Both are needed for an end-to-end search path.
28
+ - read and nav are derived defaults of every local Service; rg is provider-free when the rg binary is installed.
29
+ - QMD provider setup makes indexed search/update available. It is not required for the baseline Service path.
28
30
 
29
31
  Model:
30
32
  - A UKP Service is a knowledge endpoint that declares capabilities.
31
33
  - Four separate journeys:
32
- - provider path: init service + register make a folder an addressable Service;
33
- - content-searchable: provider setup decides what content is indexed (provider-owned; see 'ukp guide service qmd');
34
+ - provider-free Service path: init service + diagnose + register make a folder addressable, then nav/read/rg use endpoint files directly;
35
+ - indexed-search path: provider setup decides what content is indexed (provider-owned; see 'ukp guide service qmd');
34
36
  - declared dependencies: a Service Manifest can declare [[dependencies]] entries with endpoint + kind; this is declarative only;
35
37
  - client path: a workspace .ukp/client.toml default scope lets you use Services by default instead of naming one each call.
36
- - Registered as a Service does not mean its content is searchable; both steps are needed.
38
+ - Registered as a Service does not mean indexed search is configured; use rg for the baseline lexical path or add provider setup for ukp search.
37
39
  - QMD is the current default search/update provider, not the definition of a Service.
38
40
  - Provider setup is provider-owned: the provider owns collection, index, ranking, and config.
39
41
 
@@ -46,49 +48,53 @@ Details:
46
48
  ukp init service --name your-endpoint-name
47
49
  Optional: add --description "Short boundary note"
48
50
  Name must be a lowercase endpoint slug, for example: cad-notes
49
- This writes .ukp/service.toml with the current minimal provider declaration:
50
- [capabilities.search]
51
- provider = "qmd"
52
-
53
- 3. Make content searchable through the provider (provider-owned)
54
- Provider setup is not a UKP step. For the default QMD provider:
55
- ukp guide service qmd
56
- That topic points exact flags and arguments to the relevant QMD subcommand help.
51
+ This writes .ukp/service.toml with the provider-free baseline:
52
+ [capabilities]
57
53
 
54
+ 3. Optional: declare dependencies
58
55
  If this Service depends on other externally consumed knowledge endpoints,
59
56
  declare them in the Manifest with repeated --dependency <name> entries;
60
- each entry becomes [[dependencies]] with kind = "context" and can be edited later.
57
+ each entry becomes [[dependencies]] with kind = "context" and can be
58
+ edited later (authority, implementation, evidence, or a reason).
61
59
 
62
60
  4. Validate and register
63
61
  ukp diagnose
64
62
  ukp register
65
63
  ukp list
66
64
  At this point the folder is a registered Service: addressable and callable.
67
- Its content is searchable only because step 3 configured the provider.
65
+ Its file-native baseline is available through read/nav, and rg works when rg is installed.
68
66
 
69
67
  5. Inspect the effective route
70
68
  ukp inspect --endpoint your-endpoint-name
71
69
  Use inspect when you need to explain scope, Registry binding, Manifest capability, or provider availability.
72
70
 
73
- 6. Search
71
+ 6. Explore without a provider
72
+ ukp nav --endpoint your-endpoint-name --depth 2
73
+ ukp rg "keyword" --endpoint your-endpoint-name --limit 3
74
+ rg results carry ukp:// references that can be read directly.
75
+
76
+ 7. Add indexed search when you need provider-backed search/update
77
+ Provider setup is not a UKP baseline step. For the default QMD provider:
78
+ ukp guide service qmd
79
+ That topic points exact flags and arguments to the relevant QMD subcommand help.
74
80
  ukp search "keyword" --endpoint your-endpoint-name --limit 3
75
81
  ukp search "keyword" --endpoint your-endpoint-name --limit 3 --json
76
82
  ukp search "keyword" --endpoint your-endpoint-name --recursive
77
83
  In a workspace with .ukp/client.toml default_endpoints, you can omit --endpoint for search.
78
84
 
79
- 7. Read an endpoint-scoped reference
85
+ 8. Read an endpoint-scoped reference
80
86
  Every registered local Service has the derived read/file baseline; no Manifest get entry is needed.
81
87
  ukp read --endpoint your-endpoint-name docs/example.md
82
88
  ukp read --endpoint your-endpoint-name docs/example.md --lines 10:20
83
- Search results carry copyable handoff lines you can paste straight into read:
89
+ rg and search results carry copyable handoff lines you can paste straight into read:
84
90
  - a ukp://<endpoint>/<path> URI is the durable, endpoint-scoped reference;
85
91
  - a bare docid is the provider's short-lived document id (QMD handoff key).
86
92
  On a QMD-backed Service (including observe mode), provider content is read with
87
93
  'ukp read --endpoint <name> <docid handoff key or qmd:// reference>'; do not
88
94
  manually convert qmd:// provider locations into Service-folder paths.
89
95
 
90
- 8. Update provider-owned state
91
- This is optional for the first search path. Add this capability to .ukp/service.toml when provider maintenance should be exposed:
96
+ 9. Update provider-owned state
97
+ This is optional and only applies after a provider-backed maintenance capability is declared. Add this capability to .ukp/service.toml when provider maintenance should be exposed:
92
98
  [capabilities.update]
93
99
  provider = "qmd"
94
100
  ukp update --endpoint your-endpoint-name
@@ -96,19 +102,14 @@ Details:
96
102
  Use ukp update -g only when you explicitly want to update every registered endpoint.
97
103
 
98
104
  Advanced:
99
- - Declared dependencies belong in the Service Manifest as [[dependencies]] entries.
100
- - ukp init service --dependency <name> emits a context dependency; edit the Manifest later for authority, implementation, evidence, or a reason.
101
105
  - Dependencies do not change ordinary search/read. Only explicit search --recursive traverses direct authority/context dependencies.
102
106
  - Workspace defaults belong to the client path; run 'ukp guide client' from the consuming workspace.
103
- - update is a provider-owned maintenance route, not part of the first search smoke.
104
107
 
105
108
  Remember:
106
109
  - Endpoint name identifies the Service.
107
110
  - Host Registry stores endpoint name -> Service folder path.
108
111
  - Provider collection decides what content is indexed; folder-external authoritative knowledge should be modeled as its own endpoint when UKP ownership matters.
109
- - Register makes the folder addressable; it does not make content searchable (provider indexing does).
110
112
  - Register does not edit .ukp/client.toml or provider configuration.
111
- - Declarative dependencies live in the Service Manifest as [[dependencies]] entries; they do not change ordinary search/read, while explicit search --recursive traverses direct authority/context dependencies.
112
113
  - Future providers should add provider adapters instead of turning provider internals into UKP rules.
113
114
  - To register an already-used name at a new location, unregister the old binding first:
114
115
  ukp unregister --endpoint <name>
@@ -21,9 +21,10 @@ const GUIDE_CONTENT_URL = new URL("./guide-content/", import.meta.url);
21
21
  * the inversion of root help describing guides better than guide's own help
22
22
  * was a confirmed doc-gap). */
23
23
  export const GUIDE_TOPICS: ReadonlyArray<readonly [topic: string, summary: string]> = [
24
- ["service", "first Service setup, inspect, search, read, and update path"],
24
+ ["service", "first Service setup with provider-free nav/read/rg baseline"],
25
25
  ["service qmd", "provider setup for the default QMD provider"],
26
26
  ["client", "use registered Services by default from a workspace; set your default scope here"],
27
+ ["remote", "serve and consume remote endpoints with a host door, ssh, or TLS"],
27
28
  ["propose", "submit idempotent change proposals to a Service"],
28
29
  ];
29
30
 
@@ -37,7 +38,7 @@ export const GUIDE_SPEC: UkpCommandSpec = {
37
38
  description: "Show short operational guides.",
38
39
  usage: "<topic> [subtopic]",
39
40
  arguments: [
40
- { name: "topic", required: true, help: "guide topic: service | service qmd | client | propose" },
41
+ { name: "topic", required: true, help: "guide topic: service | service qmd | client | remote | propose" },
41
42
  { name: "subtopic", help: "provider subtopic for a topic, e.g. 'qmd' as in 'ukp guide service qmd'" },
42
43
  ],
43
44
  strictArguments: true,
@@ -71,15 +72,17 @@ export function executeGuideCommand(args: readonly string[]): GuideCommandResult
71
72
  return { exitCode: 0, stdout: renderServiceGuide(), stderr: "" };
72
73
  }
73
74
  if (subtopic) {
74
- throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, propose`);
75
+ throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, remote, propose`);
75
76
  }
76
77
  switch (topic) {
77
78
  case "client":
78
79
  return { exitCode: 0, stdout: renderClientGuide(), stderr: "" };
80
+ case "remote":
81
+ return { exitCode: 0, stdout: renderRemoteGuide(), stderr: "" };
79
82
  case "propose":
80
83
  return { exitCode: 0, stdout: renderProposeGuide(), stderr: "" };
81
84
  default:
82
- throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, propose`);
85
+ throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, remote, propose`);
83
86
  }
84
87
  } catch (error) {
85
88
  if (error instanceof HelpRequestError) {
@@ -112,6 +115,10 @@ export function renderClientGuide(): string {
112
115
  return readGuideContent("client.txt");
113
116
  }
114
117
 
118
+ export function renderRemoteGuide(): string {
119
+ return readGuideContent("remote.txt");
120
+ }
121
+
115
122
  export function renderProposeGuide(): string {
116
123
  return readGuideContent("propose.txt");
117
124
  }
@@ -54,17 +54,17 @@ export const INIT_SERVICE_SPEC: UkpCommandSpec = {
54
54
  ],
55
55
  strictArguments: true,
56
56
  helpSuffix: [
57
- "Creates .ukp/service.toml with the current minimal search provider:",
58
- " [capabilities.search]",
59
- " provider = \"qmd\"",
57
+ "Creates .ukp/service.toml with the provider-free Service baseline:",
58
+ " [capabilities]",
60
59
  "Optional:",
60
+ " ukp guide service qmd add QMD-backed indexed search/update setup",
61
61
  " --dependency <name> declare another endpoint as a contextual dependency entry",
62
62
  " each --dependency emits [[dependencies]] with endpoint + kind = \"context\"",
63
63
  "",
64
64
  "Next:",
65
- " ukp guide service qmd",
66
65
  " ukp diagnose",
67
66
  " ukp register",
67
+ " ukp nav / ukp read / ukp rg",
68
68
  "",
69
69
  ].join("\n"),
70
70
  };
@@ -134,11 +134,7 @@ function renderServiceManifest(options: { name?: string; description?: string; d
134
134
  ...(options.name === undefined ? {} : { name: options.name }),
135
135
  ...(options.description === undefined ? {} : { description: options.description }),
136
136
  ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
137
- capabilities: {
138
- search: {
139
- provider: "qmd",
140
- },
141
- },
137
+ capabilities: {},
142
138
  });
143
139
  return encoded.endsWith("\n") ? encoded : `${encoded}\n`;
144
140
  }
@@ -191,11 +187,19 @@ function executeInitServiceCommand(args: readonly string[], context: InitCommand
191
187
  `name_source: ${result.nameSource}`,
192
188
  `manifest: ${result.manifestPath}`,
193
189
  `location: ${result.folder}`,
194
- "capability: search",
195
- "provider: qmd",
196
- "next: ukp guide service qmd",
190
+ "declared_capabilities: -",
191
+ "derived_capabilities: read, nav",
192
+ "provider_free: rg (if rg is installed)",
193
+ // Folder-existence probe only (no provider state read): an existing
194
+ // provider index means collections may already be registered — route
195
+ // to the provider-scoped topic instead of naming provider commands
196
+ // (provider-agnostic stdout, same pattern as diagnose's hint).
197
+ ...(existsSync(join(result.folder, ".qmd"))
198
+ ? ["note: existing provider index detected (.qmd/); align collection naming before adding new ones (see 'ukp guide service qmd')"]
199
+ : []),
197
200
  "next: ukp diagnose",
198
201
  "next: ukp register",
202
+ "optional: ukp guide service qmd",
199
203
  ].join("\n"),
200
204
  stderr: "",
201
205
  };