@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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
executeKitCommand,
|
|
3
|
+
parseKitArgs,
|
|
4
|
+
renderKitHelp,
|
|
5
|
+
KitUsageError,
|
|
6
|
+
type KitCommandResult,
|
|
7
|
+
type KitParsed,
|
|
8
|
+
type UkpCommandSpec,
|
|
9
|
+
} from "./kit.ts";
|
|
10
|
+
import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
|
|
11
|
+
|
|
12
|
+
export interface ServeCommandContext {
|
|
13
|
+
currentDirectory: string;
|
|
14
|
+
registryPath: string;
|
|
15
|
+
qmdCommand?: readonly string[];
|
|
16
|
+
/** Overrides `process.env.UKP_SERVE_TOKEN` for tests. */
|
|
17
|
+
tokens?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `UKP_SERVE_TOKEN=a,b,c` (RQ-16): comma-separated, trimmed, empty entries
|
|
21
|
+
* dropped — any listed token authorizes. */
|
|
22
|
+
function parseServeTokens(raw: string | undefined): string[] {
|
|
23
|
+
if (raw === undefined) return [];
|
|
24
|
+
return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Single-source command spec (ADR 0024): long-running host command — the
|
|
28
|
+
* Bun.serve listener keeps the process alive after runCli returns its exit
|
|
29
|
+
* code; SIGINT/SIGTERM stop the listener so the process exits with it.
|
|
30
|
+
* `--endpoint` is optional (W7 / ADR-REM-004): present = the original
|
|
31
|
+
* single-endpoint mode; absent = host door mode serving the whole registry
|
|
32
|
+
* over `/e/<name>/` routing. */
|
|
33
|
+
export const SERVE_SPEC: UkpCommandSpec = {
|
|
34
|
+
name: "serve",
|
|
35
|
+
summary: "serve one endpoint — or the whole registry as a host door — over HTTP for remote UKP clients",
|
|
36
|
+
group: "operations",
|
|
37
|
+
description:
|
|
38
|
+
"Expose a registered endpoint over HTTP using the ukp-remote wire, or — without --endpoint — serve every local endpoint as one host door routed by name.",
|
|
39
|
+
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls | --tls-cert <pem> --tls-key <pem>]",
|
|
40
|
+
options: [
|
|
41
|
+
{ flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
|
|
42
|
+
{ flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
|
|
43
|
+
{ flags: "--port <n>", help: "listen port (default 8570)" },
|
|
44
|
+
{ flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under .ukp/tls/, SAN covers this host's addresses; clients pin it at registration)" },
|
|
45
|
+
{ flags: "--tls-cert <pem>", help: "TLS certificate (chain) PEM path — Let's Encrypt, mkcert, or a private CA; pair with --tls-key" },
|
|
46
|
+
{ flags: "--tls-key <pem>", help: "TLS private key PEM path; pair with --tls-cert" },
|
|
47
|
+
{ flags: "--allow-anonymous", help: "permit tokenless access on loopback (local testing, or an ssh-forwarded host door where SSH carries encryption and auth; reverse-proxy deployments still require UKP_SERVE_TOKEN)" },
|
|
48
|
+
],
|
|
49
|
+
helpSuffix: [
|
|
50
|
+
"",
|
|
51
|
+
"Wire (ukp-remote v1), single endpoint (--endpoint <name>):",
|
|
52
|
+
` GET ${DISCOVERY_PATH} discovery document (manifest projection,`,
|
|
53
|
+
" protocol version, instance identity)",
|
|
54
|
+
" POST /v1/search {query, limit} -> ukp.search.v1 envelope",
|
|
55
|
+
" GET /v1/read?ref=…|uri=… endpoint-relative ref or ukp:// URI",
|
|
56
|
+
" GET /v1/nav?path=&depth= markdown route view (ukp.nav.v1)",
|
|
57
|
+
" GET /v1/rg?query=… lexical search (ukp.rg.v1, ukp_uri handoff)",
|
|
58
|
+
"",
|
|
59
|
+
"Wire (ukp-remote v1), host door (no --endpoint; ADR-REM-004):",
|
|
60
|
+
` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster)`,
|
|
61
|
+
" GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
|
|
62
|
+
" as single-endpoint mode; TOFU pins anchor here)",
|
|
63
|
+
" POST /e/<name>/v1/search capabilities routed by endpoint name",
|
|
64
|
+
" GET /e/<name>/v1/read|nav|rg",
|
|
65
|
+
"",
|
|
66
|
+
"Auth (deny by default, RQ-18):",
|
|
67
|
+
" Serving requires UKP_SERVE_TOKEN (comma-separate several: alice,bob;",
|
|
68
|
+
" any listed token authorizes). Tokenless serving is an explicit opt-in:",
|
|
69
|
+
" --allow-anonymous, loopback binds only. The discovery document stays",
|
|
70
|
+
" public. A reverse proxy on the same host forwards from the public side",
|
|
71
|
+
" to the loopback bind, so proxy deployments treat the token as",
|
|
72
|
+
" mandatory — serve cannot see past its own bind address.",
|
|
73
|
+
"",
|
|
74
|
+
" Loopback-without-token covers testing/dogfood AND the ssh door",
|
|
75
|
+
" deployment: bind the door on the remote host's loopback and let ssh",
|
|
76
|
+
" forwarding carry encryption and authentication (clients register",
|
|
77
|
+
" 'ukp register --url ssh://<host>' — zero tokens, docker",
|
|
78
|
+
" DOCKER_HOST=ssh:// posture). It is not the way to consume a",
|
|
79
|
+
" same-machine endpoint — register its local path instead.",
|
|
80
|
+
" TLS (W5'): pass --tls to serve HTTPS with a self-signed identity",
|
|
81
|
+
" (generated under .ukp/tls/ — under the registry directory in door",
|
|
82
|
+
" mode —, SAN covers this host's addresses; remote clients TOFU-pin it",
|
|
83
|
+
" at registration and refresh by re-registering), or --tls-cert/--tls-key",
|
|
84
|
+
" for your own certificate (Let's Encrypt — IP certs available since",
|
|
85
|
+
" 2026-01 —, mkcert, a private CA). Plain HTTP remains loopback-only by",
|
|
86
|
+
" admission; public exposure needs TLS or the ssh:// transport.",
|
|
87
|
+
"",
|
|
88
|
+
].join("\n"),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export interface ParsedServe {
|
|
92
|
+
/** Present = single-endpoint mode; absent = host door mode (W7). */
|
|
93
|
+
endpoint?: string;
|
|
94
|
+
host: string;
|
|
95
|
+
port: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
99
|
+
const options = parsed.options as { host?: string; port?: string; endpoint?: string };
|
|
100
|
+
const endpoint = options.endpoint;
|
|
101
|
+
if (endpoint !== undefined && endpoint.length === 0) {
|
|
102
|
+
throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
|
|
103
|
+
}
|
|
104
|
+
const host = options.host ?? "127.0.0.1";
|
|
105
|
+
const port = Number(options.port ?? "8570");
|
|
106
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
107
|
+
throw new KitUsageError("--port must be an integer between 1 and 65535");
|
|
108
|
+
}
|
|
109
|
+
return { ...(endpoint !== undefined ? { endpoint } : {}), host, port };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseServeArgs(args: readonly string[]): ParsedServe {
|
|
113
|
+
return toParsedServe(parseKitArgs(SERVE_SPEC, args));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isLoopback(host: string): boolean {
|
|
117
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderServeBanner(info: ServeInfo): string {
|
|
121
|
+
if (info.mode === "door") {
|
|
122
|
+
return [
|
|
123
|
+
`serving host door (ukp-remote v1)`,
|
|
124
|
+
` listening: ${info.url}`,
|
|
125
|
+
` discovery: ${info.url}${DISCOVERY_PATH} (host door)`,
|
|
126
|
+
` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none — register endpoints on this host)"}`,
|
|
127
|
+
` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
|
|
128
|
+
` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
|
|
129
|
+
...(info.tls !== undefined
|
|
130
|
+
? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
|
|
131
|
+
: []),
|
|
132
|
+
"",
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
return [
|
|
136
|
+
`serving endpoint '${info.endpoint}' (ukp-remote v1)`,
|
|
137
|
+
` listening: ${info.url}`,
|
|
138
|
+
` discovery: ${info.url}${DISCOVERY_PATH}`,
|
|
139
|
+
` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
|
|
140
|
+
...(info.tls !== undefined
|
|
141
|
+
? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
|
|
142
|
+
: []),
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Auth admission (RQ-18, web-standard deny-by-default): a token is required
|
|
148
|
+
* to start serving; tokenless access is an explicit, loopback-only opt-in.
|
|
149
|
+
* A reverse proxy on the same host forwards from the public side to the
|
|
150
|
+
* loopback bind, so the proxy recipes treat UKP_SERVE_TOKEN as mandatory —
|
|
151
|
+
* serve itself cannot see past its own bind address. */
|
|
152
|
+
export function serveAuthDecision(
|
|
153
|
+
host: string,
|
|
154
|
+
tokens: readonly string[],
|
|
155
|
+
allowAnonymous: boolean,
|
|
156
|
+
): { ok: true; authRequired: boolean } | { ok: false; reason: string } {
|
|
157
|
+
if (tokens.length > 0) return { ok: true, authRequired: true };
|
|
158
|
+
if (!isLoopback(host)) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: `refusing to serve on non-loopback ${host} without a token: set UKP_SERVE_TOKEN`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (allowAnonymous) return { ok: true, authRequired: false };
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: "serve requires authentication: set UKP_SERVE_TOKEN, or pass --allow-anonymous for tokenless loopback testing (a reverse proxy on the same host still requires UKP_SERVE_TOKEN)",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function executeServeCommand(
|
|
172
|
+
args: readonly string[],
|
|
173
|
+
context: ServeCommandContext,
|
|
174
|
+
): KitCommandResult {
|
|
175
|
+
return executeKitCommand(SERVE_SPEC, args, (parsed) => {
|
|
176
|
+
const { endpoint, host, port } = toParsedServe(parsed);
|
|
177
|
+
const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
|
|
178
|
+
const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
|
|
179
|
+
// TLS flag family (W5'): --tls and --tls-cert/--tls-key are mutually
|
|
180
|
+
// exclusive; the explicit pair must arrive complete.
|
|
181
|
+
if (options.tls === true && (options.tlsCert !== undefined || options.tlsKey !== undefined)) {
|
|
182
|
+
throw new KitUsageError("--tls and --tls-cert/--tls-key are mutually exclusive");
|
|
183
|
+
}
|
|
184
|
+
if (options.tls !== true && (options.tlsCert !== undefined) !== (options.tlsKey !== undefined)) {
|
|
185
|
+
throw new KitUsageError("--tls-cert and --tls-key are used together");
|
|
186
|
+
}
|
|
187
|
+
const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
|
|
188
|
+
const decision = serveAuthDecision(host, tokens, allowAnonymous);
|
|
189
|
+
if (!decision.ok) {
|
|
190
|
+
const target = endpoint === undefined ? "host door" : `'${endpoint}'`;
|
|
191
|
+
throw new Error(`refusing to serve ${target}: ${decision.reason}`);
|
|
192
|
+
}
|
|
193
|
+
const { server, info } = startUkpServer({
|
|
194
|
+
currentDirectory: context.currentDirectory,
|
|
195
|
+
registryPath: context.registryPath,
|
|
196
|
+
qmdCommand: context.qmdCommand,
|
|
197
|
+
host,
|
|
198
|
+
port,
|
|
199
|
+
...(endpoint !== undefined ? { endpointName: endpoint } : {}),
|
|
200
|
+
...(tokens.length > 0 ? { tokens } : {}),
|
|
201
|
+
...(options.tls === true
|
|
202
|
+
? { tls: { mode: "self-signed" as const } }
|
|
203
|
+
: options.tlsCert !== undefined && options.tlsKey !== undefined
|
|
204
|
+
? { tls: { mode: "certificates" as const, certPath: options.tlsCert, keyPath: options.tlsKey } }
|
|
205
|
+
: {}),
|
|
206
|
+
});
|
|
207
|
+
const stop = () => {
|
|
208
|
+
console.error(`ukp serve: stopped (${info.url})`);
|
|
209
|
+
server.stop(true);
|
|
210
|
+
};
|
|
211
|
+
process.once("SIGINT", stop);
|
|
212
|
+
process.once("SIGTERM", stop);
|
|
213
|
+
// Exit code 0 flows out through runCli; the server listener keeps the
|
|
214
|
+
// process alive until the signal handler stops it.
|
|
215
|
+
return { exitCode: 0, stdout: renderServeBanner(info), stderr: "" };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function renderServeHelp(): string {
|
|
220
|
+
return renderKitHelp(SERVE_SPEC);
|
|
221
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
executeKitCommand,
|
|
3
|
+
parseKitArgs,
|
|
4
|
+
renderKitHelp,
|
|
5
|
+
type KitCommandResult,
|
|
6
|
+
type KitParsed,
|
|
7
|
+
type UkpCommandSpec,
|
|
8
|
+
} from "./kit.ts";
|
|
9
|
+
import {
|
|
10
|
+
runUpdate,
|
|
11
|
+
renderUpdateHuman,
|
|
12
|
+
type ParsedUpdate,
|
|
13
|
+
type UpdateAggregateStatus,
|
|
14
|
+
type UpdateContext,
|
|
15
|
+
} from "../capabilities/update.ts";
|
|
16
|
+
|
|
17
|
+
export type { UpdateContext } from "../capabilities/update.ts";
|
|
18
|
+
|
|
19
|
+
/** CLI-owned command result shape (ADR 0021). */
|
|
20
|
+
export interface UpdateCommandResult extends KitCommandResult {}
|
|
21
|
+
|
|
22
|
+
/** Aggregate classification → exit code (ADR 0021). */
|
|
23
|
+
const UPDATE_EXIT_BY_AGGREGATE: Record<UpdateAggregateStatus, number> = {
|
|
24
|
+
"succeeded": 0,
|
|
25
|
+
"failed": 1,
|
|
26
|
+
"no-success": 1,
|
|
27
|
+
"cancelled": 130,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Single-source command spec (ADR 0024 pilot command): `summary` feeds the
|
|
31
|
+
* root help via cli.ts; `usage` feeds both the help header and the
|
|
32
|
+
* usage-error line; the scope family, singleton detection, help-intent
|
|
33
|
+
* triage, and catch chain live in kit.ts. */
|
|
34
|
+
export const UPDATE_SPEC: UkpCommandSpec = {
|
|
35
|
+
name: "update",
|
|
36
|
+
summary: "trigger provider-owned Service maintenance",
|
|
37
|
+
group: "operations",
|
|
38
|
+
description: "Trigger provider-owned maintenance for selected Service endpoints.",
|
|
39
|
+
usage: "[--endpoint <name> ... | -g]",
|
|
40
|
+
scope: {
|
|
41
|
+
endpointHelp: "update one endpoint; repeat to update multiple endpoints",
|
|
42
|
+
globalHelp: "update every endpoint in the Host Registry; takes no value",
|
|
43
|
+
},
|
|
44
|
+
helpSuffix: [
|
|
45
|
+
"",
|
|
46
|
+
"Scope:",
|
|
47
|
+
" With no --endpoint or -g, the workspace default scope applies: the",
|
|
48
|
+
" Client Config's default endpoints ('ukp inspect' shows the resolved",
|
|
49
|
+
" scope). Without a Client Config, update needs an explicit scope:",
|
|
50
|
+
" pass --endpoint <name> or -g. -g selects every endpoint in the Host",
|
|
51
|
+
" Registry and cannot be combined with --endpoint. Maintenance runs",
|
|
52
|
+
" the provider's own update step (QMD: 'qmd update'), updating what",
|
|
53
|
+
" its index covers; the endpoint selector never maps to a provider",
|
|
54
|
+
" collection.",
|
|
55
|
+
"",
|
|
56
|
+
].join("\n"),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function toParsedUpdate(parsed: KitParsed): ParsedUpdate {
|
|
60
|
+
return {
|
|
61
|
+
options: {
|
|
62
|
+
explicitEndpoints: parsed.scope.explicitEndpoints,
|
|
63
|
+
global: parsed.scope.global,
|
|
64
|
+
},
|
|
65
|
+
warnings: parsed.scope.warnings,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseUpdateArgs(args: readonly string[]): ParsedUpdate {
|
|
70
|
+
return toParsedUpdate(parseKitArgs(UPDATE_SPEC, args));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** CLI composition of the structured outcome (ADR 0021); kept as the test
|
|
74
|
+
* entry so suites exercise the exact adapter path the bin takes. */
|
|
75
|
+
export function executeUpdate(parsed: ParsedUpdate, context: UpdateContext): UpdateCommandResult {
|
|
76
|
+
const result = runUpdate(parsed, context);
|
|
77
|
+
const view = renderUpdateHuman(result);
|
|
78
|
+
return {
|
|
79
|
+
exitCode: UPDATE_EXIT_BY_AGGREGATE[result.aggregate],
|
|
80
|
+
stdout: view.body,
|
|
81
|
+
stderr: view.diagnostics,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function executeUpdateCommand(args: readonly string[], context: UpdateContext): UpdateCommandResult {
|
|
86
|
+
return executeKitCommand(
|
|
87
|
+
UPDATE_SPEC,
|
|
88
|
+
args,
|
|
89
|
+
(parsed) => executeUpdate(toParsedUpdate(parsed), context),
|
|
90
|
+
{
|
|
91
|
+
scopeErrorHint:
|
|
92
|
+
"Hint: run 'ukp list' to inspect registrations, 'ukp register' from a Service folder to add one, or pass '-g' to explicitly update every registered endpoint.",
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderUpdateHelp(): string {
|
|
98
|
+
return renderKitHelp(UPDATE_SPEC);
|
|
99
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "smol-toml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { assertRestrictedToml, ENDPOINT_NAME, ManifestError } from "./manifest.ts";
|
|
6
|
+
|
|
7
|
+
const clientConfigSchema = z.object({
|
|
8
|
+
default_endpoints: z.array(z.string().min(1)).min(1),
|
|
9
|
+
}).strict();
|
|
10
|
+
|
|
11
|
+
export interface ClientConfig {
|
|
12
|
+
default_endpoints: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadClientConfig(path: string): ClientConfig {
|
|
16
|
+
let source: string;
|
|
17
|
+
try {
|
|
18
|
+
source = readFileSync(path, "utf8");
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new ManifestError(`Client Config is not readable: ${path}`, { cause: error });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let raw: unknown;
|
|
24
|
+
try {
|
|
25
|
+
raw = parse(source);
|
|
26
|
+
assertRestrictedToml(raw);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof ManifestError) throw error;
|
|
29
|
+
throw new ManifestError(`Client Config TOML is invalid: ${path}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = clientConfigSchema.safeParse(raw);
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
throw new ManifestError(`Client Config schema is invalid: ${result.error.message}`);
|
|
35
|
+
}
|
|
36
|
+
const names = result.data.default_endpoints;
|
|
37
|
+
if (names.some((name) => !ENDPOINT_NAME.test(name))) {
|
|
38
|
+
throw new ManifestError(`${path}: default_endpoints contains an invalid endpoint name`);
|
|
39
|
+
}
|
|
40
|
+
if (new Set(names).size !== names.length) {
|
|
41
|
+
throw new ManifestError(`${path}: default_endpoints contains duplicate endpoint names`);
|
|
42
|
+
}
|
|
43
|
+
return result.data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findNearestClientConfig(currentDirectory: string): string | undefined {
|
|
47
|
+
let directory = currentDirectory;
|
|
48
|
+
while (true) {
|
|
49
|
+
const candidate = join(directory, ".ukp", "client.toml");
|
|
50
|
+
if (existsSync(candidate)) return candidate;
|
|
51
|
+
const parent = join(directory, "..");
|
|
52
|
+
if (parent === directory) return undefined;
|
|
53
|
+
directory = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ADR-RG-003: external-tool base tier — the table governing capabilities
|
|
2
|
+
// backed by a ubiquitous external tool with graceful degradation. Mirrors
|
|
3
|
+
// the file-native table (ADR 0016): the set membership, the base-tier rule
|
|
4
|
+
// (available on every registered local Service; a declaration only
|
|
5
|
+
// overrides), and the provider marker live here and nowhere else.
|
|
6
|
+
//
|
|
7
|
+
// Tier criteria (stricter than file-native): the capability semantics must
|
|
8
|
+
// be fully defined by UKP (thin shaping, no tool-semantic wrap), read-only,
|
|
9
|
+
// dependent on a *ubiquitous* base tool (ripgrep-class), and degrade to an
|
|
10
|
+
// availability classification when the tool is missing — never a fault.
|
|
11
|
+
|
|
12
|
+
import type { Manifest, ManifestCapability } from "./manifest.ts";
|
|
13
|
+
|
|
14
|
+
/** Provider marker for the external-tool base tier. Manifest load defaults a
|
|
15
|
+
* bare `[capabilities.<name>]` declaration to this value; any other provider
|
|
16
|
+
* is a hard error (the tier does not plugin alternative tools). */
|
|
17
|
+
export const EXTERNAL_PROVIDER = "external";
|
|
18
|
+
|
|
19
|
+
export type ExternalToolCapabilityName = "rg";
|
|
20
|
+
|
|
21
|
+
export interface ExternalToolCapabilitySpec {
|
|
22
|
+
/** Base tier (ADR-RG-003 rule): available on every registered local
|
|
23
|
+
* Service without declaration; a declaration only overrides config. */
|
|
24
|
+
readonly baseTier: boolean;
|
|
25
|
+
/** Flat keys accepted directly under `[capabilities.<name>]`. */
|
|
26
|
+
readonly flatConfigKeys: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const EXTERNAL_TOOL_CAPABILITIES: Readonly<
|
|
30
|
+
Record<ExternalToolCapabilityName, ExternalToolCapabilitySpec>
|
|
31
|
+
> = {
|
|
32
|
+
rg: { baseTier: true, flatConfigKeys: [] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function isExternalToolCapability(name: string): name is ExternalToolCapabilityName {
|
|
36
|
+
return Object.hasOwn(EXTERNAL_TOOL_CAPABILITIES, name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExternalToolResolution {
|
|
40
|
+
readonly capability: ManifestCapability;
|
|
41
|
+
readonly source: "manifest" | "base-tier";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolves the effective external-tool capability declaration for a
|
|
45
|
+
* Service: the Manifest declaration when present, otherwise the base-tier
|
|
46
|
+
* default (`{ provider: "external" }`). Never `undefined` for base-tier
|
|
47
|
+
* members — unlike the file-native set there is no write-side member that
|
|
48
|
+
* requires explicit declaration. */
|
|
49
|
+
export function resolveExternalToolCapability(
|
|
50
|
+
manifest: Manifest,
|
|
51
|
+
name: ExternalToolCapabilityName,
|
|
52
|
+
): ExternalToolResolution {
|
|
53
|
+
const declared = manifest.capabilities[name];
|
|
54
|
+
if (declared !== undefined) {
|
|
55
|
+
return { capability: declared, source: "manifest" };
|
|
56
|
+
}
|
|
57
|
+
return { capability: { provider: EXTERNAL_PROVIDER }, source: "base-tier" };
|
|
58
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ADR 0016: file-native capability set — the single table governing every
|
|
2
|
+
// file-native capability. Three rules live here and nowhere else:
|
|
3
|
+
//
|
|
4
|
+
// 1. the set membership (`read`, `nav`, `propose`);
|
|
5
|
+
// 2. which members are derived defaults of every registered local Service
|
|
6
|
+
// (read-only, exposure within the read/file baseline) vs. which still
|
|
7
|
+
// require an explicit declaration (write-side: `propose`);
|
|
8
|
+
// 3. which flat declaration keys each capability accepts directly under
|
|
9
|
+
// `[capabilities.<name>]` (normalized into `config` at Manifest load).
|
|
10
|
+
//
|
|
11
|
+
// Capability implementations and command surfaces resolve their declaration
|
|
12
|
+
// through this module instead of re-implementing per-capability fallbacks,
|
|
13
|
+
// so a new file-native capability joins by editing this table only.
|
|
14
|
+
|
|
15
|
+
import type { Manifest, ManifestCapability } from "./manifest.ts";
|
|
16
|
+
|
|
17
|
+
export type FileNativeCapabilityName = "read" | "nav" | "propose";
|
|
18
|
+
|
|
19
|
+
export interface FileNativeCapabilitySpec {
|
|
20
|
+
/** Derived default of every registered local Service (ADR 0016 rule 2):
|
|
21
|
+
* present without any declaration; a declaration only overrides defaults. */
|
|
22
|
+
readonly derived: boolean;
|
|
23
|
+
/** Flat keys accepted directly under `[capabilities.<name>]` (flat UX, no
|
|
24
|
+
* `.config` hop). Normalized into `config` at load; a key present both
|
|
25
|
+
* flat and under `.config` is a declaration conflict. */
|
|
26
|
+
readonly flatConfigKeys: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const FILE_NATIVE_CAPABILITIES: Readonly<Record<FileNativeCapabilityName, FileNativeCapabilitySpec>> = {
|
|
30
|
+
// `read` is in the set for uniformity (rule 1); its bare-declaration arm is
|
|
31
|
+
// unreachable in practice because a declared [capabilities.get] is stripped
|
|
32
|
+
// as legacy before provider defaulting — read's derived baseline has no
|
|
33
|
+
// Manifest override surface at all (ADR 0007 / ADR 0016).
|
|
34
|
+
read: { derived: true, flatConfigKeys: [] },
|
|
35
|
+
nav: { derived: true, flatConfigKeys: ["exclude_files", "exclude_dirs"] },
|
|
36
|
+
propose: { derived: false, flatConfigKeys: [] },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function isFileNativeCapability(name: string): name is FileNativeCapabilityName {
|
|
40
|
+
return Object.hasOwn(FILE_NATIVE_CAPABILITIES, name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Load-time normalization of flat declaration keys into `config`, driven by
|
|
48
|
+
* the table above. `fail` lets the Manifest loader keep its own error type
|
|
49
|
+
* (single ManifestError surface); the message text is owned here. Pure: does
|
|
50
|
+
* not mutate the input. */
|
|
51
|
+
export function normalizeFileNativeFlatKeys(
|
|
52
|
+
raw: unknown,
|
|
53
|
+
fail: (message: string) => never,
|
|
54
|
+
): unknown {
|
|
55
|
+
if (!isRecord(raw) || !isRecord(raw.capabilities)) return raw;
|
|
56
|
+
const output: Record<string, unknown> = { ...raw.capabilities };
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const [name, declaration] of Object.entries(raw.capabilities)) {
|
|
59
|
+
if (!isFileNativeCapability(name) || !isRecord(declaration)) continue;
|
|
60
|
+
const flatKeys = FILE_NATIVE_CAPABILITIES[name].flatConfigKeys;
|
|
61
|
+
if (!flatKeys.some((key) => Object.hasOwn(declaration, key))) continue;
|
|
62
|
+
const config = isRecord(declaration.config) ? { ...declaration.config } : {};
|
|
63
|
+
const next: Record<string, unknown> = { ...declaration };
|
|
64
|
+
for (const key of flatKeys) {
|
|
65
|
+
if (!Object.hasOwn(next, key)) continue;
|
|
66
|
+
if (Object.hasOwn(config, key)) {
|
|
67
|
+
fail(`[capabilities.${name}] '${key}' is declared both directly and under [capabilities.${name}.config]`);
|
|
68
|
+
}
|
|
69
|
+
config[key] = next[key];
|
|
70
|
+
delete next[key];
|
|
71
|
+
}
|
|
72
|
+
next.config = config;
|
|
73
|
+
output[name] = next;
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
return changed ? { ...raw, capabilities: output } : raw;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FileNativeResolution {
|
|
80
|
+
readonly capability: ManifestCapability;
|
|
81
|
+
readonly source: "manifest" | "derived-local";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolves the effective file-native capability declaration for a Service:
|
|
85
|
+
* the Manifest declaration when present, otherwise the derived default
|
|
86
|
+
* (`{ provider: "file" }`) for derived members. `undefined` means the
|
|
87
|
+
* capability is not effectively present — today only an undeclared write-side
|
|
88
|
+
* capability (`propose`), whose caller reports the missing declaration. */
|
|
89
|
+
export function resolveFileNativeCapability(
|
|
90
|
+
manifest: Manifest,
|
|
91
|
+
name: FileNativeCapabilityName,
|
|
92
|
+
): FileNativeResolution | undefined {
|
|
93
|
+
const declared = manifest.capabilities[name];
|
|
94
|
+
if (declared !== undefined) {
|
|
95
|
+
return { capability: declared, source: "manifest" };
|
|
96
|
+
}
|
|
97
|
+
return FILE_NATIVE_CAPABILITIES[name].derived
|
|
98
|
+
? { capability: { provider: "file" }, source: "derived-local" }
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Uniform unsupported-provider message for every file-native capability
|
|
103
|
+
* (only the `file` provider exists; a different provider is a hard error at
|
|
104
|
+
* every surface). The `(none)` arm is defensive depth only — Manifest load
|
|
105
|
+
* already defaults a bare declaration to `"file"` and fails fast elsewhere. */
|
|
106
|
+
export function unsupportedFileNativeProviderMessage(
|
|
107
|
+
name: FileNativeCapabilityName,
|
|
108
|
+
provider: string | undefined,
|
|
109
|
+
): string {
|
|
110
|
+
return `unsupported ${name} provider '${provider ?? "(none)"}' (supported: file)`;
|
|
111
|
+
}
|