@catheadowl/ukp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,436 @@
1
+ import {
2
+ closeSync,
3
+ copyFileSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ renameSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { isAbsolute, dirname } from "node:path";
16
+ import { randomUUID } from "node:crypto";
17
+ import { parse, stringify } from "smol-toml";
18
+ import { z } from "zod";
19
+ import { assertRestrictedToml, ENDPOINT_NAME, ManifestError } from "./config/manifest.ts";
20
+ import { acquireLock, LockBusyError, releaseLock } from "./fslock.ts";
21
+
22
+ const bindingSchema = z.object({
23
+ name: z.string().min(1),
24
+ path: z.string().min(1).optional(),
25
+ /** Discriminator written only for remote bindings (D-077); absence = local. */
26
+ kind: z.literal("remote").optional(),
27
+ url: z.string().min(1).optional(),
28
+ instance_uid: z.string().min(1).optional(),
29
+ /** Client credential stored in the binding (D-078: plaintext, 0600 file —
30
+ * AWS credentials-file / netrc convention; env override wins at call time). */
31
+ token: z.string().min(1).optional(),
32
+ /** TLS trust anchor pinned at registration (W5' / D-079, self-signed
33
+ * servers only — public-CA chains are not pinned): `tls_cert` is the PEM
34
+ * used as the fetch trust anchor, `tls_pin` is the RFC 7469 SPKI pin that
35
+ * survives certificate renewals which keep the key. Both or neither. */
36
+ tls_cert: z.string().min(1).optional(),
37
+ tls_pin: z.string().regex(/^sha256\/[A-Za-z0-9+/]+={0,2}$/).optional(),
38
+ }).strict();
39
+
40
+ const registrySchema = z.object({
41
+ endpoints: z.array(bindingSchema),
42
+ }).strict();
43
+
44
+ /** Binding union over the `kind` axis (D-077). Local bindings keep the
45
+ * historical `{name, path}` shape (no injected `kind` field), so parsed
46
+ * locals stay byte-identical to the pre-remote era; remote bindings carry
47
+ * `kind: "remote"` with `url` and an optional TOFU-pinned `instance_uid`.
48
+ * Field exclusivity is enforced by validateBindings, not the type. */
49
+ export interface RegistryBinding {
50
+ name: string;
51
+ /** Local canonical absolute path (local bindings only). */
52
+ path?: string;
53
+ kind?: "remote";
54
+ url?: string;
55
+ instance_uid?: string;
56
+ /** Remote-only stored credential (plaintext; env takes precedence). */
57
+ token?: string;
58
+ /** Remote-only TLS anchor (W5' / D-079); see bindingSchema. */
59
+ tls_cert?: string;
60
+ tls_pin?: string;
61
+ }
62
+
63
+ export function isRemoteBinding(binding: RegistryBinding): boolean {
64
+ return binding.kind === "remote";
65
+ }
66
+
67
+ /** Local path accessor with a classified error for remote bindings — the
68
+ * single narrowing point local-filesystem consumers route through. */
69
+ export function localPathOf(binding: RegistryBinding): string {
70
+ if (binding.kind === "remote" || binding.path === undefined) {
71
+ throw new RegistryError(
72
+ `endpoint '${binding.name}' is a remote binding (${binding.url}); it has no local Service folder`,
73
+ );
74
+ }
75
+ return binding.path;
76
+ }
77
+
78
+ export function isLoopbackHttpUrl(raw: string): boolean {
79
+ let parsed: URL;
80
+ try {
81
+ parsed = new URL(raw);
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (parsed.protocol !== "http:") return false;
86
+ const host = parsed.hostname.toLowerCase();
87
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
88
+ }
89
+
90
+ /** Remote url decomposition (D-078 + W7 / ADR-REM-004 O-3): every admissible
91
+ * remote url is `scheme://[user@]host[:port][/endpoint]` — the optional
92
+ * single path segment is the door-endpoint selector (`ssh://ali/notes`,
93
+ * git's `ssh://[user@]host[:port]/<path>` shape). `origin` is the canonical
94
+ * door address (default port absorbed, userinfo preserved) that grouping,
95
+ * drift notes, and re-registration are keyed on. Multi-segment or
96
+ * invalid-name paths make the whole url inadmissible. */
97
+ export interface RemoteUrlParts {
98
+ scheme: "ssh" | "https" | "http";
99
+ /** ssh userinfo (`ssh://user@host`), preserved in `origin` and the tunnel target. */
100
+ user?: string;
101
+ host: string;
102
+ /** Explicit or scheme default (ssh 8570, https 443, http 80). */
103
+ port: number;
104
+ /** Present when the url carries the single door-endpoint path segment. */
105
+ endpointName?: string;
106
+ /** Canonical origin string (`ssh://ali`, `https://kb.example.com:8570`). */
107
+ origin: string;
108
+ }
109
+
110
+ const REMOTE_DEFAULT_PORTS: Record<RemoteUrlParts["scheme"], number> = { ssh: 8570, https: 443, http: 80 };
111
+
112
+ export function parseRemoteUrl(raw: string): RemoteUrlParts | undefined {
113
+ let parsed: URL;
114
+ try {
115
+ parsed = new URL(raw);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ const scheme = parsed.protocol.replace(/:$/, "");
120
+ if (scheme !== "ssh" && scheme !== "https" && scheme !== "http") return undefined;
121
+ if (scheme === "http" && !isLoopbackHttpUrl(raw)) return undefined;
122
+ const host = parsed.hostname;
123
+ if (host.length === 0) return undefined;
124
+ const port = parsed.port === "" ? REMOTE_DEFAULT_PORTS[scheme] : Number(parsed.port);
125
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return undefined;
126
+ const path = parsed.pathname.replace(/\/+$/, "");
127
+ if (path.includes("//")) return undefined;
128
+ let endpointName: string | undefined;
129
+ if (path !== "") {
130
+ const segment = path.replace(/^\//, "");
131
+ if (segment.includes("/") || !ENDPOINT_NAME.test(segment)) return undefined;
132
+ endpointName = segment;
133
+ }
134
+ const user = scheme === "ssh" && parsed.username !== "" ? parsed.username : undefined;
135
+ const origin =
136
+ `${scheme}://${user !== undefined ? `${user}@` : ""}${host}${port !== REMOTE_DEFAULT_PORTS[scheme] ? `:${port}` : ""}`;
137
+ return {
138
+ scheme,
139
+ ...(user !== undefined ? { user } : {}),
140
+ host,
141
+ port,
142
+ ...(endpointName !== undefined ? { endpointName } : {}),
143
+ origin,
144
+ };
145
+ }
146
+
147
+ /** `ssh://[user@]host[:port][/endpoint]` transport scheme (D-078; path
148
+ * segment since W7): the client tunnels to the remote host's loopback over
149
+ * SSH — encryption + host auth come from SSH, so the scheme is admissible
150
+ * wherever https is. */
151
+ export function parseSshUrl(raw: string): { user?: string; host: string; port: number; endpointName?: string } | undefined {
152
+ const parts = parseRemoteUrl(raw);
153
+ if (parts === undefined || parts.scheme !== "ssh") return undefined;
154
+ return {
155
+ ...(parts.user !== undefined ? { user: parts.user } : {}),
156
+ host: parts.host,
157
+ port: parts.port,
158
+ ...(parts.endpointName !== undefined ? { endpointName: parts.endpointName } : {}),
159
+ };
160
+ }
161
+
162
+ /** Remote URL admission (ADR-REM-003 §7 + D-078; path semantics W7): https
163
+ * always; plain http only on loopback (local dogfood); ssh://[user@]host[:port]
164
+ * tunnels via SSH; the path, if any, must be a single endpoint name.
165
+ * Enforced at registration AND at call time. */
166
+ export function assertRemoteUrlAllowed(raw: string): void {
167
+ if (parseRemoteUrl(raw) !== undefined) return;
168
+ let parsed: URL;
169
+ try {
170
+ parsed = new URL(raw);
171
+ } catch {
172
+ throw new RegistryError(`remote endpoint url is not a valid absolute URL: ${raw}`);
173
+ }
174
+ const scheme = parsed.protocol.replace(/:$/, "");
175
+ const schemeAdmitted =
176
+ scheme === "https" || scheme === "ssh" || (scheme === "http" && isLoopbackHttpUrl(raw));
177
+ if (schemeAdmitted) {
178
+ const path = parsed.pathname.replace(/\/+$/, "");
179
+ const segment = path.replace(/^\//, "");
180
+ if (path !== "" && (segment.includes("/") || !ENDPOINT_NAME.test(segment))) {
181
+ throw new RegistryError(
182
+ `remote endpoint url path must be a single endpoint name ([a-z0-9-], one segment): ${raw}`,
183
+ );
184
+ }
185
+ // Admitted scheme, well-formed (empty) path: the failure is structural
186
+ // (empty host and the like) — the admission line names the valid shape.
187
+ }
188
+ throw new RegistryError(
189
+ `remote endpoint url must be https, ssh://host[:port], or loopback http: ${raw} (bare-IP https: 'ukp serve --tls')`,
190
+ );
191
+ }
192
+
193
+ export class RegistryError extends Error {
194
+ constructor(message: string, options?: ErrorOptions) {
195
+ super(message, options);
196
+ this.name = "RegistryError";
197
+ }
198
+ }
199
+
200
+ export class RegistryBusyError extends RegistryError {
201
+ constructor(path: string) {
202
+ super(
203
+ `registry_busy: ${path} (another ukp process holds the registry lock; ` +
204
+ `stale locks are reclaimed automatically — close other ukp commands or retry shortly)`,
205
+ );
206
+ this.name = "RegistryBusyError";
207
+ }
208
+ }
209
+ function validateBindings(endpoints: readonly RegistryBinding[]): RegistryBinding[] {
210
+ const names = new Set<string>();
211
+ const paths = new Set<string>();
212
+ const urls = new Set<string>();
213
+ for (const endpoint of endpoints) {
214
+ if (!ENDPOINT_NAME.test(endpoint.name)) {
215
+ throw new RegistryError(`invalid endpoint name '${endpoint.name}'`);
216
+ }
217
+ if (endpoint.kind === "remote") {
218
+ if (endpoint.path !== undefined) {
219
+ throw new RegistryError(`remote binding '${endpoint.name}' must not carry a local path`);
220
+ }
221
+ if (endpoint.url === undefined) {
222
+ throw new RegistryError(`remote binding '${endpoint.name}' requires a url`);
223
+ }
224
+ if ((endpoint.tls_cert !== undefined) !== (endpoint.tls_pin !== undefined)) {
225
+ throw new RegistryError(`remote binding '${endpoint.name}' must carry tls_cert and tls_pin together`);
226
+ }
227
+ assertRemoteUrlAllowed(endpoint.url);
228
+ if (urls.has(endpoint.url)) throw new RegistryError(`duplicate remote endpoint url '${endpoint.url}'`);
229
+ urls.add(endpoint.url);
230
+ } else {
231
+ if (
232
+ endpoint.url !== undefined || endpoint.instance_uid !== undefined || endpoint.token !== undefined
233
+ || endpoint.tls_cert !== undefined || endpoint.tls_pin !== undefined
234
+ ) {
235
+ throw new RegistryError(`local binding '${endpoint.name}' must not carry remote fields (url/instance_uid/token/tls)`);
236
+ }
237
+ if (endpoint.path === undefined || !isAbsolute(endpoint.path)) {
238
+ throw new RegistryError(`registry path must be absolute: ${endpoint.path ?? "(missing)"}`);
239
+ }
240
+ if (paths.has(endpoint.path)) throw new RegistryError(`duplicate endpoint location '${endpoint.path}'`);
241
+ paths.add(endpoint.path);
242
+ }
243
+ if (names.has(endpoint.name)) throw new RegistryError(`duplicate endpoint name '${endpoint.name}'`);
244
+ names.add(endpoint.name);
245
+ }
246
+ return [...endpoints].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
247
+ }
248
+
249
+ export function parseRegistry(source: string, path = "registry.toml"): RegistryBinding[] {
250
+ if (source.trim().length === 0) throw new RegistryError(`Registry is empty: ${path}`);
251
+ let raw: unknown;
252
+ try {
253
+ raw = parse(source);
254
+ assertRestrictedToml(raw);
255
+ } catch (error) {
256
+ if (error instanceof RegistryError) throw error;
257
+ if (error instanceof ManifestError) throw new RegistryError(error.message, { cause: error });
258
+ throw new RegistryError(`Registry TOML is invalid: ${path}`, { cause: error });
259
+ }
260
+ const result = registrySchema.safeParse(raw);
261
+ if (!result.success) throw new RegistryError(`Registry schema is invalid: ${result.error.message}`);
262
+ return validateBindings(result.data.endpoints);
263
+ }
264
+
265
+ export function readRegistry(registryPath: string): RegistryBinding[] {
266
+ if (!existsSync(registryPath)) return [];
267
+ try {
268
+ return parseRegistry(readFileSync(registryPath, "utf8"), registryPath);
269
+ } catch (error) {
270
+ if (error instanceof RegistryError) throw error;
271
+ throw new RegistryError(`Registry is not readable: ${registryPath}`, { cause: error });
272
+ }
273
+ }
274
+
275
+ /** Canonical serialization (configuration-contracts canonical form): local rows
276
+ * `{name, path}`, remote rows `{name, kind, url, instance_uid?}` — key order
277
+ * is insertion order, so build plain ordered objects before stringify. */
278
+ function toSerializableBinding(binding: RegistryBinding): Record<string, string> {
279
+ if (binding.kind === "remote") {
280
+ return {
281
+ name: binding.name,
282
+ kind: "remote",
283
+ url: binding.url!,
284
+ ...(binding.instance_uid !== undefined ? { instance_uid: binding.instance_uid } : {}),
285
+ ...(binding.token !== undefined ? { token: binding.token } : {}),
286
+ ...(binding.tls_cert !== undefined ? { tls_cert: binding.tls_cert } : {}),
287
+ ...(binding.tls_pin !== undefined ? { tls_pin: binding.tls_pin } : {}),
288
+ };
289
+ }
290
+ return { name: binding.name, path: binding.path! };
291
+ }
292
+
293
+ export function serializeRegistry(endpoints: readonly RegistryBinding[]): string {
294
+ const sorted = validateBindings(endpoints);
295
+ const value = { endpoints: sorted.map(toSerializableBinding) };
296
+ const encoded = stringify(value);
297
+ // Re-parse serializer output through the same validation pipeline.
298
+ parseRegistry(encoded, "serialized registry");
299
+ return encoded.endsWith("\n") ? encoded : `${encoded}\n`;
300
+ }
301
+
302
+ export function registerBinding(endpoints: readonly RegistryBinding[], binding: RegistryBinding): RegistryBinding[] {
303
+ if (binding.kind === "remote") return registerRemoteBinding(endpoints, binding);
304
+ const canonical: RegistryBinding = { name: binding.name, path: realpathSync(binding.path!) };
305
+ const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
306
+ if (sameName) {
307
+ if (sameName.kind !== "remote" && sameName.path === canonical.path) return validateBindings(endpoints);
308
+ throw new RegistryError(
309
+ `endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}`,
310
+ );
311
+ }
312
+ const samePath = endpoints.find((endpoint) => endpoint.kind !== "remote" && endpoint.path === canonical.path);
313
+ if (samePath) {
314
+ throw new RegistryError(`Service location is already bound to '${samePath.name}'`);
315
+ }
316
+ return validateBindings([...endpoints, canonical]);
317
+ }
318
+
319
+ /** Remote registration (D-077): name comes from the discovery document
320
+ * (RQ-14), instance_uid is the TOFU pin. Idempotent on same name+url. */
321
+ export function registerRemoteBinding(
322
+ endpoints: readonly RegistryBinding[],
323
+ binding: RegistryBinding,
324
+ ): RegistryBinding[] {
325
+ const canonical: RegistryBinding = {
326
+ name: binding.name,
327
+ kind: "remote",
328
+ url: binding.url!,
329
+ ...(binding.instance_uid !== undefined ? { instance_uid: binding.instance_uid } : {}),
330
+ ...(binding.token !== undefined ? { token: binding.token } : {}),
331
+ ...(binding.tls_cert !== undefined ? { tls_cert: binding.tls_cert } : {}),
332
+ ...(binding.tls_pin !== undefined ? { tls_pin: binding.tls_pin } : {}),
333
+ };
334
+ const sameName = endpoints.find((endpoint) => endpoint.name === canonical.name);
335
+ if (sameName) {
336
+ if (sameName.kind === "remote" && sameName.url === canonical.url) {
337
+ // Same name+url re-registration refreshes the TOFU pin (service
338
+ // replacement is an explicit re-register, ADR-REM-003).
339
+ return validateBindings([...endpoints.filter((e) => e.name !== canonical.name), canonical]);
340
+ }
341
+ throw new RegistryError(
342
+ `endpoint name '${canonical.name}' is already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}`,
343
+ );
344
+ }
345
+ const sameUrl = endpoints.find((endpoint) => endpoint.kind === "remote" && endpoint.url === canonical.url);
346
+ if (sameUrl) {
347
+ throw new RegistryError(`remote endpoint url is already bound to '${sameUrl.name}'`);
348
+ }
349
+ return validateBindings([...endpoints, canonical]);
350
+ }
351
+
352
+ export function unregisterBinding(endpoints: readonly RegistryBinding[], name: string): RegistryBinding[] {
353
+ const index = endpoints.findIndex((endpoint) => endpoint.name === name);
354
+ if (index < 0) throw new RegistryError(`endpoint not found: ${name}`);
355
+ return validateBindings(endpoints.filter((_, candidateIndex) => candidateIndex !== index));
356
+ }
357
+
358
+ export function mutateRegistry(
359
+ registryPath: string,
360
+ mutation: (current: RegistryBinding[]) => RegistryBinding[],
361
+ ): RegistryBinding[] {
362
+ const directory = dirname(registryPath);
363
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
364
+ const lockPath = `${registryPath}.lock`;
365
+ let lockDescriptor: number;
366
+ try {
367
+ lockDescriptor = acquireLock(lockPath);
368
+ } catch (error) {
369
+ if (error instanceof LockBusyError) throw new RegistryBusyError(lockPath);
370
+ throw new RegistryError(`cannot acquire Registry lock: ${lockPath}`, { cause: error });
371
+ }
372
+ let tempPath: string | undefined;
373
+ let backupTempPath: string | undefined;
374
+ try {
375
+ const current = readRegistry(registryPath);
376
+ const next = validateBindings(mutation(current));
377
+ const encoded = serializeRegistry(next);
378
+ tempPath = `${registryPath}.tmp.${randomUUID()}`;
379
+ const tempDescriptor = openSync(tempPath, "wx", 0o600);
380
+ try {
381
+ writeFileSync(tempDescriptor, encoded, "utf8");
382
+ fsyncSync(tempDescriptor);
383
+ } finally {
384
+ closeSync(tempDescriptor);
385
+ }
386
+
387
+ if (existsSync(registryPath)) {
388
+ backupTempPath = `${registryPath}.bak.tmp.${randomUUID()}`;
389
+ copyFileSync(registryPath, backupTempPath);
390
+ const backupDescriptor = openSync(backupTempPath, "r+");
391
+ try { fsyncSync(backupDescriptor); } finally { closeSync(backupDescriptor); }
392
+ renameSync(backupTempPath, `${registryPath}.bak`);
393
+ backupTempPath = undefined;
394
+ }
395
+ renameSync(tempPath, registryPath);
396
+ tempPath = undefined;
397
+ return next;
398
+ } finally {
399
+ if (tempPath && existsSync(tempPath)) unlinkSync(tempPath);
400
+ if (backupTempPath && existsSync(backupTempPath)) unlinkSync(backupTempPath);
401
+ releaseLock(lockPath, lockDescriptor);
402
+ }
403
+ }
404
+
405
+ export function registerAt(registryPath: string, name: string, servicePath: string): RegistryBinding[] {
406
+ const canonicalPath = realpathSync(servicePath);
407
+ if (!statSync(canonicalPath).isDirectory()) throw new RegistryError(`Service location is not a directory: ${servicePath}`);
408
+ return mutateRegistry(registryPath, (current) => registerBinding(current, { name, path: canonicalPath }));
409
+ }
410
+
411
+ export function registerRemoteAt(
412
+ registryPath: string,
413
+ binding: { name: string; url: string; instance_uid?: string; token?: string; tls_cert?: string; tls_pin?: string },
414
+ ): RegistryBinding[] {
415
+ return mutateRegistry(registryPath, (current) => registerRemoteBinding(current, binding));
416
+ }
417
+
418
+ /** Renewal re-anchor (W5' / D-079): swap a pinned binding's stored trust
419
+ * anchor PEM after the server renewed its certificate with the same key
420
+ * (SPKI pin unchanged — that is the identity). Called from the read path,
421
+ * where persistence is best-effort: the in-memory anchor already unblocks
422
+ * the invocation, so lock contention must not fail the user-facing call. */
423
+ export function refreshRemoteTlsCert(registryPath: string, name: string, tls_cert: string): RegistryBinding[] {
424
+ return mutateRegistry(registryPath, (current) => {
425
+ const binding = current.find((endpoint) => endpoint.name === name && endpoint.kind === "remote");
426
+ if (binding === undefined) throw new RegistryError(`endpoint not found: ${name}`);
427
+ if (binding.tls_pin === undefined) {
428
+ throw new RegistryError(`remote binding '${name}' carries no TLS pin; re-register instead of re-anchoring`);
429
+ }
430
+ return current.map((endpoint) => endpoint.name === name && endpoint.kind === "remote" ? { ...endpoint, tls_cert } : endpoint);
431
+ });
432
+ }
433
+
434
+ export function unregisterAt(registryPath: string, name: string): RegistryBinding[] {
435
+ return mutateRegistry(registryPath, (current) => unregisterBinding(current, name));
436
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { findNearestClientConfig, loadClientConfig } from "./config/client.ts";
2
+ import type { RegistryBinding } from "./registry.ts";
3
+
4
+ export type { RegistryBinding };
5
+
6
+ export interface DanglingEndpoint {
7
+ name: string;
8
+ configPath: string;
9
+ /** Position in `default_endpoints` declaration order, so consumers can interleave
10
+ * dangling entries with resolved bindings at their declared position. */
11
+ index: number;
12
+ }
13
+
14
+ export interface ScopeInput {
15
+ currentDirectory: string;
16
+ registry: readonly RegistryBinding[];
17
+ explicitEndpoints?: readonly string[];
18
+ global?: boolean;
19
+ }
20
+
21
+ export interface ResolvedScope {
22
+ source: "explicit" | "global" | "client-config" | "registry-fallback";
23
+ bindings: RegistryBinding[];
24
+ /** Unresolved `default_endpoints` references, in declaration order (client-config source only). */
25
+ dangling: DanglingEndpoint[];
26
+ warnings: string[];
27
+ configPath?: string;
28
+ }
29
+
30
+ export class ScopeError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "ScopeError";
34
+ }
35
+ }
36
+
37
+ export function resolveScope(input: ScopeInput): ResolvedScope {
38
+ const explicit = input.explicitEndpoints;
39
+ if (input.global && explicit !== undefined) {
40
+ throw new ScopeError("explicit endpoint scope and global scope cannot be used together");
41
+ }
42
+
43
+ const byName = new Map(input.registry.map((binding) => [binding.name, binding]));
44
+ if (explicit !== undefined) {
45
+ const bindings = explicit.map((name) => {
46
+ const binding = byName.get(name);
47
+ if (!binding) throw new ScopeError(`unknown endpoint '${name}'`);
48
+ return binding;
49
+ });
50
+ return { source: "explicit", bindings, dangling: [], warnings: [] };
51
+ }
52
+
53
+ if (input.global) {
54
+ return { source: "global", bindings: [...input.registry], dangling: [], warnings: [] };
55
+ }
56
+
57
+ const configPath = findNearestClientConfig(input.currentDirectory);
58
+ if (!configPath) {
59
+ return { source: "registry-fallback", bindings: [...input.registry], dangling: [], warnings: [] };
60
+ }
61
+
62
+ const config = loadClientConfig(configPath);
63
+ const warnings: string[] = [];
64
+ const dangling: DanglingEndpoint[] = [];
65
+ const bindings: RegistryBinding[] = [];
66
+ config.default_endpoints.forEach((name, index) => {
67
+ const binding = byName.get(name);
68
+ if (binding) bindings.push(binding);
69
+ else {
70
+ dangling.push({ name, configPath, index });
71
+ warnings.push(`'${name}' is not registered (from ${configPath})`);
72
+ }
73
+ });
74
+ if (bindings.length === 0) {
75
+ throw new ScopeError("Client Config does not resolve to any registered endpoint");
76
+ }
77
+ return { source: "client-config", bindings, dangling, warnings, configPath };
78
+ }