@catheadowl/ukp 0.2.6 → 0.3.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/README.md +83 -16
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +86 -8
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -0
- package/src/capabilities/nav.ts +209 -444
- package/src/capabilities/qmd.ts +19 -0
- package/src/capabilities/read.ts +18 -2
- package/src/capabilities/remote-client.ts +4 -2
- package/src/capabilities/search.ts +23 -7
- package/src/capabilities/session.ts +455 -0
- package/src/capabilities/tls-identity.ts +54 -3
- package/src/cli.ts +32 -64
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +21 -0
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +224 -9
- package/src/commands/inventory.ts +277 -17
- package/src/commands/nav.ts +17 -12
- package/src/commands/read.ts +1 -1
- package/src/commands/rg.ts +2 -2
- package/src/commands/search.ts +19 -3
- package/src/commands/serve-task.ts +5 -5
- package/src/commands/serve.ts +313 -29
- package/src/commands/session.ts +541 -0
- package/src/commands/use.ts +178 -0
- package/src/config/agent-backed.ts +136 -0
- package/src/config/manifest.ts +39 -3
- package/src/manifest.yml +15 -0
- package/src/path-safety.ts +9 -4
- package/src/plumbing/token-file.ts +368 -0
- package/src/registry.ts +78 -1
- package/src/server.ts +228 -45
- package/src/spawn-relay.ts +21 -3
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { executeReadCommand, READ_SPEC } from "./commands/read.ts";
|
|
|
13
13
|
import { executeRgCommand, RG_SPEC } from "./commands/rg.ts";
|
|
14
14
|
import { executeGuideCommand, GUIDE_SPEC, GUIDE_TOPICS } from "./commands/guide.ts";
|
|
15
15
|
import { executeInitCommand, INIT_SPEC } from "./commands/init.ts";
|
|
16
|
+
import { executeSessionCommand } from "./commands/session.ts";
|
|
16
17
|
import { executeInspectCommand, INSPECT_SPEC } from "./commands/inspect.ts";
|
|
17
18
|
import { executeListCommand, executeRegisterCommand, executeUnregisterCommand, LIST_SPEC, REGISTER_SPEC, UNREGISTER_SPEC } from "./commands/inventory.ts";
|
|
18
19
|
import { executeNavCommand, NAV_SPEC } from "./commands/nav.ts";
|
|
@@ -20,6 +21,7 @@ import { executeProposeCommand, PROPOSE_SPEC } from "./commands/propose.ts";
|
|
|
20
21
|
import { executeUpdateCommand, UPDATE_SPEC } from "./commands/update.ts";
|
|
21
22
|
import { executeServeCommand, SERVE_SPEC } from "./commands/serve.ts";
|
|
22
23
|
import { executeSearchCommand, SEARCH_SPEC } from "./commands/search.ts";
|
|
24
|
+
import { executeUseCommand, USE_SPEC } from "./commands/use.ts";
|
|
23
25
|
|
|
24
26
|
export { renderSearchHelp } from "./commands/search.ts";
|
|
25
27
|
export { renderDiagnoseHelp } from "./commands/diagnose.ts";
|
|
@@ -54,6 +56,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
54
56
|
register: REGISTER_SPEC.summary,
|
|
55
57
|
unregister: UNREGISTER_SPEC.summary,
|
|
56
58
|
list: LIST_SPEC.summary,
|
|
59
|
+
use: USE_SPEC.summary,
|
|
57
60
|
nav: NAV_SPEC.summary,
|
|
58
61
|
propose: PROPOSE_SPEC.summary,
|
|
59
62
|
search: SEARCH_SPEC.summary,
|
|
@@ -66,7 +69,10 @@ export const COMMANDS = Object.entries(COMMAND_DESCRIPTIONS).map(
|
|
|
66
69
|
|
|
67
70
|
// Help-display groups (ADR 0022): purpose-noun headings over a flat verb
|
|
68
71
|
// surface. Every command must appear in exactly one group; a guard test
|
|
69
|
-
// locks group membership against COMMANDS.
|
|
72
|
+
// locks group membership against COMMANDS. `session` is deliberately
|
|
73
|
+
// outside the advertised surface (owner ruling 2026-11-03, extending the
|
|
74
|
+
// 2026-10-31 contract-face exit): dispatch stays live, the skill face
|
|
75
|
+
// documents it — hidden but functional, the clig.dev shape.
|
|
70
76
|
export const COMMAND_GROUPS = [
|
|
71
77
|
{
|
|
72
78
|
heading: "Endpoint commands",
|
|
@@ -74,7 +80,7 @@ export const COMMAND_GROUPS = [
|
|
|
74
80
|
},
|
|
75
81
|
{
|
|
76
82
|
heading: "Registry commands",
|
|
77
|
-
commands: ["init", "register", "unregister", "list"],
|
|
83
|
+
commands: ["init", "register", "unregister", "list", "use"],
|
|
78
84
|
},
|
|
79
85
|
{
|
|
80
86
|
heading: "Operations commands",
|
|
@@ -92,6 +98,9 @@ export interface CliContext {
|
|
|
92
98
|
resolveProvider?: ProviderResolver;
|
|
93
99
|
qmdCommand?: readonly string[];
|
|
94
100
|
rgCommand?: readonly string[];
|
|
101
|
+
/** Overrides the amux binary behind `ukp session` (wrapper mechanism,
|
|
102
|
+
* rg.ts rgCommand precedent). */
|
|
103
|
+
amuxCommand?: readonly string[];
|
|
95
104
|
artifactRoot?: string;
|
|
96
105
|
artifactRunId?: string;
|
|
97
106
|
now?: Date;
|
|
@@ -200,7 +209,9 @@ export function renderHelp(): string {
|
|
|
200
209
|
),
|
|
201
210
|
"",
|
|
202
211
|
]),
|
|
203
|
-
"Endpoint names for --endpoint come from 'ukp list'.",
|
|
212
|
+
"Endpoint names for --endpoint come from 'ukp list -g' (every registered endpoint); plain 'ukp list' shows the workspace scope.",
|
|
213
|
+
"",
|
|
214
|
+
"ukp:// references (ukp://<endpoint>/<path>) address registered endpoints, not web pages; open one with 'ukp read <uri>'.",
|
|
204
215
|
"",
|
|
205
216
|
"A Service's declared capabilities are backed by a provider (QMD, an external tool, backs search and update today); rg and nav work on endpoint files directly, no provider needed.",
|
|
206
217
|
"",
|
|
@@ -226,51 +237,6 @@ function writeCommandResult(
|
|
|
226
237
|
return result.exitCode;
|
|
227
238
|
}
|
|
228
239
|
|
|
229
|
-
/** Channel philosophy for `ukp list` (ukp_list W2): stdout is the data
|
|
230
|
-
* channel — byte contract, one row per line, the terminal wraps what it
|
|
231
|
-
* must; stderr to an INTERACTIVE terminal is the human channel — progress
|
|
232
|
-
* frames already live there, and the footnote lines (degradation warnings,
|
|
233
|
-
* drift notes) may exceed any terminal width (pins are ~44 chars, urls
|
|
234
|
-
* longer), so the terminal would otherwise break them mid-word or
|
|
235
|
-
* mid-hash. This wraps them at word boundaries (over-long tokens
|
|
236
|
-
* hard-split at the width) — only when the emitter knows the width;
|
|
237
|
-
* piped/CI stderr keeps raw single lines, the machine-parseable form. */
|
|
238
|
-
export function wrapLinesForTerminal(text: string, columns: number): string {
|
|
239
|
-
if (!Number.isSafeInteger(columns) || columns < 8) return text;
|
|
240
|
-
const wrapped: string[] = [];
|
|
241
|
-
for (const line of text.split("\n")) {
|
|
242
|
-
if (line.length <= columns) {
|
|
243
|
-
wrapped.push(line);
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
let current = "";
|
|
247
|
-
for (const token of line.split(" ")) {
|
|
248
|
-
// A token longer than the width hard-splits at the width (paths and
|
|
249
|
-
// SPKI pins have no spaces to break on); the remainder re-enters the
|
|
250
|
-
// same greedy fill so following words still pack.
|
|
251
|
-
const pieces: string[] = [];
|
|
252
|
-
let rest = token;
|
|
253
|
-
while (rest.length > columns) {
|
|
254
|
-
pieces.push(rest.slice(0, columns));
|
|
255
|
-
rest = rest.slice(columns);
|
|
256
|
-
}
|
|
257
|
-
pieces.push(rest);
|
|
258
|
-
for (const piece of pieces) {
|
|
259
|
-
if (current.length === 0) {
|
|
260
|
-
current = piece;
|
|
261
|
-
} else if (current.length + 1 + piece.length <= columns) {
|
|
262
|
-
current += ` ${piece}`;
|
|
263
|
-
} else {
|
|
264
|
-
wrapped.push(current);
|
|
265
|
-
current = piece;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
wrapped.push(current);
|
|
270
|
-
}
|
|
271
|
-
return wrapped.join("\n");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
240
|
function executeVersionCommand(args: readonly string[]): CliCommandResult {
|
|
275
241
|
if (args.length === 0) {
|
|
276
242
|
return { exitCode: 0, stdout: renderVersion(), stderr: "" };
|
|
@@ -370,6 +336,13 @@ export function runCli(
|
|
|
370
336
|
}), stdout, stderr);
|
|
371
337
|
}
|
|
372
338
|
|
|
339
|
+
if (command === "session") {
|
|
340
|
+
return writeCommandResultMaybeAsync(executeSessionCommand(args.slice(1), {
|
|
341
|
+
registryPath,
|
|
342
|
+
amuxCommand: context.amuxCommand,
|
|
343
|
+
}), stdout, stderr);
|
|
344
|
+
}
|
|
345
|
+
|
|
373
346
|
if (command === "guide") {
|
|
374
347
|
return writeCommandResult(executeGuideCommand(args.slice(1)), stdout, stderr);
|
|
375
348
|
}
|
|
@@ -419,22 +392,21 @@ export function runCli(
|
|
|
419
392
|
}), stdout, stderr);
|
|
420
393
|
}
|
|
421
394
|
|
|
395
|
+
if (command === "use") {
|
|
396
|
+
return writeCommandResult(executeUseCommand(args.slice(1), {
|
|
397
|
+
currentDirectory,
|
|
398
|
+
registryPath,
|
|
399
|
+
}), stdout, stderr);
|
|
400
|
+
}
|
|
401
|
+
|
|
422
402
|
if (command === "list") {
|
|
423
403
|
// Streaming delivery (ukp_list W2 / ADR 0026 rule 3): line-by-line
|
|
424
404
|
// through the same stdout writer every other print uses (console.log
|
|
425
405
|
// per line ≡ the single print's trailing-newline form, so piped bytes
|
|
426
406
|
// match the returned-text contract); progress frames only on an
|
|
427
|
-
// interactive stderr, so piped runs stay progress-silent.
|
|
428
|
-
// footnotes
|
|
429
|
-
//
|
|
430
|
-
const stderrColumns = process.stderr.columns ?? process.stdout.columns;
|
|
431
|
-
const interactivelyWrap = (result: CliCommandResult): CliCommandResult =>
|
|
432
|
-
process.stderr.isTTY === true && typeof stderrColumns === "number" && result.stderr.length > 0
|
|
433
|
-
? { ...result, stderr: wrapLinesForTerminal(result.stderr, stderrColumns) }
|
|
434
|
-
: result;
|
|
435
|
-
// Conditionality preserved (the W2 sync seam): an all-local listing
|
|
436
|
-
// stays a synchronous value wrapped synchronously; only the remote
|
|
437
|
-
// branch's promise goes through .then.
|
|
407
|
+
// interactive stderr, so piped runs stay progress-silent. stderr
|
|
408
|
+
// footnotes stay raw single lines in every mode — wrapping is the
|
|
409
|
+
// terminal's business (channel philosophy, docs/spec list-command).
|
|
438
410
|
const result = executeListCommand(args.slice(1), {
|
|
439
411
|
currentDirectory,
|
|
440
412
|
registryPath,
|
|
@@ -443,11 +415,7 @@ export function runCli(
|
|
|
443
415
|
: {}),
|
|
444
416
|
emitStdout: (line: string) => stdout(line),
|
|
445
417
|
});
|
|
446
|
-
return writeCommandResultMaybeAsync(
|
|
447
|
-
result instanceof Promise ? result.then(interactivelyWrap) : interactivelyWrap(result),
|
|
448
|
-
stdout,
|
|
449
|
-
stderr,
|
|
450
|
-
);
|
|
418
|
+
return writeCommandResultMaybeAsync(result, stdout, stderr);
|
|
451
419
|
}
|
|
452
420
|
|
|
453
421
|
if (command === "nav") {
|
|
@@ -4,16 +4,24 @@ Goal: use registered Services from a workspace by default, without naming an end
|
|
|
4
4
|
|
|
5
5
|
Model:
|
|
6
6
|
- Client path is separate from the provider path: it uses Services; it does not declare one.
|
|
7
|
-
- A folder may hold both roles at once (dual-role): a Service folder may also carry its own .ukp/client.toml to set its default scope. Each file is read independently.
|
|
7
|
+
- A folder may hold both roles at once (dual-role): a Service folder may also carry its own .ukp/client.toml to set its default scope. Each file is read independently; 'ukp use' never touches the Service face.
|
|
8
8
|
- A workspace .ukp/client.toml default_endpoints list selects which registered endpoints you mean when no selector is given.
|
|
9
9
|
- UKP walks up from the current directory to find the nearest .ukp/client.toml.
|
|
10
10
|
- Client Config stores endpoint names only; bindings live in the Host Registry.
|
|
11
11
|
- Explicit --endpoint <name> or -g overrides the client defaults. With no Client Config, most commands fall back to every registered endpoint.
|
|
12
12
|
- ukp update is stricter: it does not fall back to the Registry. It uses your Client Config defaults, or requires --endpoint <name> / -g.
|
|
13
|
-
- ukp register does not edit .ukp/client.toml.
|
|
14
|
-
|
|
15
|
-
1.
|
|
16
|
-
|
|
13
|
+
- ukp register does not edit .ukp/client.toml; 'ukp use' is the one-step writer.
|
|
14
|
+
|
|
15
|
+
1. Set the workspace defaults (one step)
|
|
16
|
+
ukp use endpoint-a endpoint-b
|
|
17
|
+
Writes .ukp/client.toml (default_endpoints = the names you gave, explicit
|
|
18
|
+
replace, idempotent) and makes it version-visible in the same step:
|
|
19
|
+
.ukp/.gitignore ignores machine-local state only (the instance
|
|
20
|
+
identity), so client.toml and service.toml can be committed. Existing
|
|
21
|
+
repos carrying the retired '*' template are migrated off it (a local
|
|
22
|
+
'ukp register' does the same on the service side); operator-owned
|
|
23
|
+
ignore files are left untouched.
|
|
24
|
+
Manual form: .ukp/client.toml with
|
|
17
25
|
default_endpoints = ["endpoint-a", "endpoint-b"]
|
|
18
26
|
Each name must match a registered endpoint; run 'ukp list' to see what is registered.
|
|
19
27
|
|
|
@@ -41,8 +49,13 @@ Model:
|
|
|
41
49
|
|
|
42
50
|
Remember:
|
|
43
51
|
- Client path consumes Services; provider path (ukp guide service) creates them.
|
|
44
|
-
- Client Config lists names, not paths or provider details.
|
|
52
|
+
- Client Config lists names, not paths or provider details; 'ukp use' is the one-step writer (register still does not edit it).
|
|
45
53
|
- Explicit selectors always beat Client Config defaults.
|
|
46
54
|
- Register does not edit Client Config, and Client Config does not register endpoints.
|
|
55
|
+
- Declarations (client.toml, service.toml) are committable by design; only
|
|
56
|
+
machine-local state (.ukp/instance-uid) stays ignored - the serve TLS
|
|
57
|
+
identity lives beside the host registry (~/.ukp/tls/), never in the
|
|
58
|
+
folder. Commit the config so a fresh clone restores the workspace scope
|
|
59
|
+
once endpoints are registered.
|
|
47
60
|
- Search run artifacts live outside your folders: %LOCALAPPDATA%\ukp\artifacts
|
|
48
61
|
on Windows, ~/.cache/ukp/artifacts (or $XDG_CACHE_HOME) elsewhere.
|
|
@@ -41,6 +41,27 @@ LAN HTTPS path:
|
|
|
41
41
|
ukp read --endpoint <name> notes/x.md
|
|
42
42
|
ukp rg "keyword" --endpoint <name>
|
|
43
43
|
|
|
44
|
+
Scoped keys for a second machine or an agent (instead of sharing the env token):
|
|
45
|
+
1. On the host, issue a labeled key (run WITHOUT UKP_SERVE_TOKEN set - while the
|
|
46
|
+
env token is set it takes precedence and the key file is not consulted)
|
|
47
|
+
ukp serve token add liku "notes:ro,cad:rw" # prints the raw key ONCE
|
|
48
|
+
The key lands in ~/.ukp/tokens (0600, hash-only); scopes are name:ro|rw,
|
|
49
|
+
comma-separated (full power is the explicit '*:rw'); several keys may share
|
|
50
|
+
a label for zero-downtime rotation.
|
|
51
|
+
|
|
52
|
+
2. Serve the door with no env token (the file arms the gate; it is re-read on
|
|
53
|
+
every request)
|
|
54
|
+
ukp serve --host 0.0.0.0 --tls
|
|
55
|
+
|
|
56
|
+
3. On the consuming machine, register with the raw key as the token
|
|
57
|
+
ukp register --url https://<ip>:8570 --token <raw-key>
|
|
58
|
+
A scoped key only sees its own endpoints: out-of-scope names answer exactly
|
|
59
|
+
like nonexistent ones; a read-only scope has no propose route.
|
|
60
|
+
|
|
61
|
+
4. Revoke on the host (effective on the next request - no restart)
|
|
62
|
+
ukp serve token list
|
|
63
|
+
ukp serve token revoke liku
|
|
64
|
+
|
|
44
65
|
Single endpoint mode:
|
|
45
66
|
Use this when you deliberately want to expose only one endpoint instead of the whole host Registry.
|
|
46
67
|
UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --tls
|
|
@@ -27,8 +27,8 @@ Model:
|
|
|
27
27
|
|
|
28
28
|
4. Visibility (every rg mode)
|
|
29
29
|
Hidden files (dot-prefixed, including the .ukp/ wiring) are skipped by
|
|
30
|
-
default. Add -- --hidden after '--' to include them; -
|
|
31
|
-
.gitignore rules. 'ukp read' can address hidden files directly either way.
|
|
30
|
+
default. Add -- --hidden after '--' to include them; --no-ignore also
|
|
31
|
+
lifts .gitignore rules. 'ukp read' can address hidden files directly either way.
|
|
32
32
|
|
|
33
33
|
Remember:
|
|
34
34
|
- Exact flags and the passthrough allowlist live in 'ukp rg --help'.
|
package/src/commands/init.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, unlinkSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, unlinkSync, readFileSync, readdirSync, renameSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { stringify } from "smol-toml";
|
|
4
4
|
import { ENDPOINT_NAME, loadManifest, type ManifestDependency } from "../config/manifest.ts";
|
|
@@ -139,20 +139,231 @@ function renderServiceManifest(options: { name?: string; description?: string; d
|
|
|
139
139
|
return encoded.endsWith("\n") ? encoded : `${encoded}\n`;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
|
|
142
|
+
/** Precise self-ignore template for `.ukp/` (2026-10-28, inbox
|
|
143
|
+
* client-portability-and-skill-coverage adoption; `/tls/` line retired
|
|
144
|
+
* 2026-11-01 by the inbox service-dir-layering adjudication item 2): the
|
|
145
|
+
* tool-owned directory holds exactly two declaration files
|
|
146
|
+
* (`service.toml`, `client.toml` — pure declarations with zero machine
|
|
147
|
+
* paths, versionable by design: a fresh clone re-registers the Service
|
|
148
|
+
* with one `ukp register`) plus the machine-local state named below.
|
|
149
|
+
* Ignoring by name keeps identity out of version control and folder sync
|
|
150
|
+
* (the O-020 B-b goal) while declarations ride with the repo — the `*`
|
|
151
|
+
* blanket it replaces swallowed them (workflow-lab migration evidence:
|
|
152
|
+
* the endpoint declaration did not travel with the clone, and carving
|
|
153
|
+
* client.toml back into tracking cost three fiddly steps). The serve TLS
|
|
154
|
+
* identity no longer lives here (see UKP_SELF_IGNORE_WITH_TLS). */
|
|
155
|
+
export const UKP_SELF_IGNORE = [
|
|
156
|
+
"# UKP machine-local state (instance identity) only;",
|
|
157
|
+
"# declarations (service.toml, client.toml) are versionable by design.",
|
|
158
|
+
"/instance-uid",
|
|
159
|
+
"",
|
|
160
|
+
].join("\n");
|
|
161
|
+
|
|
162
|
+
/** The 2026-10-28 template's first form, retired 2026-11-01 when the
|
|
163
|
+
* serve TLS identity moved to the machine home (`~/.ukp/tls/<name>/`,
|
|
164
|
+
* inbox service-dir-layering item 2): its `/tls/` rule named a resident
|
|
165
|
+
* that no longer exists. Kept verbatim for two recognitions — the posture
|
|
166
|
+
* ladder classifies it "current" (it IS precise, and a folder that still
|
|
167
|
+
* carries its legacy `tls/` dir stays correctly ignored until the
|
|
168
|
+
* serve-side migration moves the material out), and the serve migration
|
|
169
|
+
* (`retireTlsIgnoreLine`) swaps it onto the current template in the same
|
|
170
|
+
* gesture that removes the directory. */
|
|
171
|
+
export const UKP_SELF_IGNORE_WITH_TLS = [
|
|
172
|
+
"# UKP machine-local state (instance identity, TLS key material) only;",
|
|
173
|
+
"# declarations (service.toml, client.toml) are versionable by design.",
|
|
174
|
+
"/instance-uid",
|
|
175
|
+
"/tls/",
|
|
176
|
+
"",
|
|
177
|
+
].join("\n");
|
|
178
|
+
|
|
179
|
+
/** The superseded 2026-09-23 `*` template (the original O-020 B-b
|
|
180
|
+
* landing), kept verbatim for recognition: the posture step below migrates
|
|
181
|
+
* a `.ukp/.gitignore` that still carries it onto the precise template;
|
|
182
|
+
* anything else is operator-owned and stays untouched (the B-b
|
|
183
|
+
* operator-policy-wins posture). */
|
|
184
|
+
export const LEGACY_UKP_SELF_IGNORE = [
|
|
150
185
|
"# UKP local Service state (manifest, identity, TLS keys) is machine-local:",
|
|
151
186
|
"# self-ignored to keep it out of version control and folder sync.",
|
|
152
187
|
"*",
|
|
153
188
|
"",
|
|
154
189
|
].join("\n");
|
|
155
190
|
|
|
191
|
+
/** Closed resident set of `.ukp/` (INV-21, owner ruling 2026-11-02): the
|
|
192
|
+
* declarations, the instance identity, the ignore guard — plus the two
|
|
193
|
+
* atomic-write transients a crash can leave behind (documented as
|
|
194
|
+
* "overwritten by the next write"; tolerating them is not an ignore rule,
|
|
195
|
+
* it is the tool owning its own residue). Anything else is a violation
|
|
196
|
+
* the posture step refuses on. The serve TLS identity left this directory
|
|
197
|
+
* on 2026-11-01 (ADR-REM-012), which is exactly why the set is closed:
|
|
198
|
+
* the template no longer carries defensive rules for residents that
|
|
199
|
+
* must not exist. */
|
|
200
|
+
const UKP_RESIDENTS: ReadonlySet<string> = new Set([
|
|
201
|
+
"service.toml",
|
|
202
|
+
"client.toml",
|
|
203
|
+
"instance-uid",
|
|
204
|
+
".gitignore",
|
|
205
|
+
".gitignore.ukp-tmp",
|
|
206
|
+
"client.toml.ukp-tmp",
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/** Refusal of a posture step over a `.ukp/` holding entries outside the
|
|
210
|
+
* closed set. Carries the offending paths so every carrier gesture can
|
|
211
|
+
* print the same operator-facing body. */
|
|
212
|
+
export class UkpResidentError extends Error {
|
|
213
|
+
constructor(
|
|
214
|
+
readonly ukpDirectory: string,
|
|
215
|
+
readonly residents: readonly string[],
|
|
216
|
+
endpointName?: string,
|
|
217
|
+
) {
|
|
218
|
+
const home = `~/.ukp/tls/${endpointName ?? "<endpoint-name>"}/`;
|
|
219
|
+
const hasTls = residents.includes("tls");
|
|
220
|
+
const others = residents.filter((name) => name !== "tls");
|
|
221
|
+
const lines = [
|
|
222
|
+
".ukp/ holds entries UKP does not own (INV-21: service.toml, client.toml, instance-uid and .gitignore only):",
|
|
223
|
+
...residents.map((name) => ` ${join(ukpDirectory, name)}`),
|
|
224
|
+
...(hasTls
|
|
225
|
+
? [`tls/ is a pre-2026-11-01 serve TLS identity: move its cert.pem and key.pem to ${home} to keep the pin clients hold, or delete it.`]
|
|
226
|
+
: []),
|
|
227
|
+
...(others.length > 0 ? [`Delete ${hasTls ? "the rest" : "them"}.`] : []),
|
|
228
|
+
"The version-control step is refused until .ukp/ is clean - rewriting the ignore guard over material it no longer covers is how a private key becomes committable.",
|
|
229
|
+
];
|
|
230
|
+
super(lines.join("\n"));
|
|
231
|
+
this.name = "UkpResidentError";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** INV-21 admission gate of every posture step (init / use / register):
|
|
236
|
+
* enumerate `.ukp/` and refuse on any entry outside the closed resident
|
|
237
|
+
* set. Runs BEFORE any write of the carrying gesture — the template
|
|
238
|
+
* rewrite most of all. The 2026-11-02 KB incident was a `*` guard swapped
|
|
239
|
+
* onto the current template while a legacy `tls/` still sat beneath it:
|
|
240
|
+
* key.pem became addable. Disposition is the owner's (delete, or move the
|
|
241
|
+
* identity to its machine home); nothing migrates here (key material
|
|
242
|
+
* relocation is serve's lifecycle), nothing is ignored, no tombstone rule
|
|
243
|
+
* is written. A missing `.ukp/` passes (nothing to inspect). */
|
|
244
|
+
export function assertUkpResidents(ukpDirectory: string, options: { endpointName?: string } = {}): void {
|
|
245
|
+
let entries: string[];
|
|
246
|
+
try {
|
|
247
|
+
entries = readdirSync(ukpDirectory);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
const strangers = entries.filter((name) => !UKP_RESIDENTS.has(name)).sort();
|
|
253
|
+
if (strangers.length === 0) return;
|
|
254
|
+
throw new UkpResidentError(ukpDirectory, strangers, options.endpointName);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Posture of `.ukp/.gitignore` after a version-control step ran. Two
|
|
258
|
+
* gestures carry the declarations' version visibility and share this
|
|
259
|
+
* ladder: `ukp use` (client face) and `ukp register`'s local path (service
|
|
260
|
+
* face — the service-side touchpoint pure Service folders were missing
|
|
261
|
+
* until 2026-11-01; they have no reason to run `use`, and re-running
|
|
262
|
+
* `init service` is refused once the manifest exists). */
|
|
263
|
+
export type UkpIgnorePosture =
|
|
264
|
+
| "created"
|
|
265
|
+
| "migrated"
|
|
266
|
+
/** The 2026-10-28 first precise form (WITH_TLS) swapped onto the current
|
|
267
|
+
* template: its `/tls/` rule named a resident INV-21 now guarantees
|
|
268
|
+
* absent — safe to drop only because the admission gate ran first. */
|
|
269
|
+
| "refreshed"
|
|
270
|
+
| "current"
|
|
271
|
+
| "operator-owned"
|
|
272
|
+
/** Operator-owned AND still carries a bare `*` rule — the declarations
|
|
273
|
+
* stay invisible; surfaced as a stderr warning with the remedy. */
|
|
274
|
+
| "operator-owned-star";
|
|
275
|
+
|
|
276
|
+
/** Same-file template swap through a temp + rename: a concurrent reader
|
|
277
|
+
* sees either template, never a torn file. */
|
|
278
|
+
function swapUkpIgnoreTemplate(ukpDirectory: string): void {
|
|
279
|
+
const tempPath = join(ukpDirectory, ".gitignore.ukp-tmp");
|
|
280
|
+
writeFileSync(tempPath, UKP_SELF_IGNORE, "utf8");
|
|
281
|
+
renameSync(tempPath, join(ukpDirectory, ".gitignore"));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Bring `.ukp/.gitignore` to the precise-ignore posture (the version
|
|
285
|
+
* control half of `ukp use` and register's local path): create when absent,
|
|
286
|
+
* migrate a verbatim legacy `*` template, refresh the retired first precise
|
|
287
|
+
* form, leave anything else untouched — the O-020 B-b operator-policy-wins
|
|
288
|
+
* rule. The `*` detector is advisory only (a note, never a rewrite). The
|
|
289
|
+
* INV-21 gate runs here too, so the rewrite itself can never land over
|
|
290
|
+
* foreign residents even if a carrier forgot to pre-gate (carriers still
|
|
291
|
+
* pre-gate before their own writes so the whole gesture fails atomically). */
|
|
292
|
+
export function ensureVersionVisibleUkpIgnore(ukpDirectory: string): UkpIgnorePosture {
|
|
293
|
+
assertUkpResidents(ukpDirectory);
|
|
294
|
+
const gitignorePath = join(ukpDirectory, ".gitignore");
|
|
295
|
+
const classify = (): UkpIgnorePosture => {
|
|
296
|
+
const content = readFileSync(gitignorePath, "utf8");
|
|
297
|
+
if (content === UKP_SELF_IGNORE) return "current";
|
|
298
|
+
if (content === LEGACY_UKP_SELF_IGNORE) {
|
|
299
|
+
swapUkpIgnoreTemplate(ukpDirectory);
|
|
300
|
+
return "migrated";
|
|
301
|
+
}
|
|
302
|
+
if (content === UKP_SELF_IGNORE_WITH_TLS) {
|
|
303
|
+
swapUkpIgnoreTemplate(ukpDirectory);
|
|
304
|
+
return "refreshed";
|
|
305
|
+
}
|
|
306
|
+
return content.split("\n").some((line) => line.trim() === "*")
|
|
307
|
+
? "operator-owned-star"
|
|
308
|
+
: "operator-owned";
|
|
309
|
+
};
|
|
310
|
+
if (!existsSync(gitignorePath)) {
|
|
311
|
+
try {
|
|
312
|
+
writeFileSync(gitignorePath, UKP_SELF_IGNORE, { flag: "wx", mode: 0o644 });
|
|
313
|
+
return "created";
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// Concurrent writer installed a guard — classify whatever landed.
|
|
316
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return classify();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** stdout line reporting a posture — shared verbatim by every carrier
|
|
323
|
+
* gesture so the version-control face reads identically wherever it runs. */
|
|
324
|
+
export function renderUkpIgnorePostureLine(posture: UkpIgnorePosture, ukpDirectory: string): string {
|
|
325
|
+
switch (posture) {
|
|
326
|
+
case "created":
|
|
327
|
+
return `version_control: .ukp/.gitignore created (machine-local state ignored; declarations committable)`;
|
|
328
|
+
case "migrated":
|
|
329
|
+
return `version_control: .ukp/.gitignore migrated off the retired '*' template (declarations committable; machine-local state stays ignored)`;
|
|
330
|
+
case "refreshed":
|
|
331
|
+
return `version_control: .ukp/.gitignore refreshed (retired tls/ rule dropped; machine-local state stays ignored)`;
|
|
332
|
+
case "current":
|
|
333
|
+
return `version_control: .ukp/.gitignore already precise (machine-local state only)`;
|
|
334
|
+
case "operator-owned":
|
|
335
|
+
return `version_control: .ukp/.gitignore is operator-owned - left untouched (${join(ukpDirectory, ".gitignore")})`;
|
|
336
|
+
case "operator-owned-star":
|
|
337
|
+
return `version_control: .ukp/.gitignore is operator-owned - left untouched (see warning)`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** stderr warning for the operator-owned-star posture: the declarations
|
|
342
|
+
* stay invisible until the operator narrows the file. */
|
|
343
|
+
export function ukpIgnoreStarWarning(ukpDirectory: string): string {
|
|
344
|
+
return `warning: ${join(ukpDirectory, ".gitignore")} ignores everything ('*'); declarations stay invisible to version control - narrow it to machine-local state (instance-uid) so they can be committed\n`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Serve-side half of the TLS identity relocation (2026-11-01): once the
|
|
348
|
+
* legacy `.ukp/tls/` material has moved into the machine home, the
|
|
349
|
+
* by-then-retired first precise template (whose `/tls/` rule guarded it)
|
|
350
|
+
* swaps onto the current one — verbatim recognition only, so an
|
|
351
|
+
* operator-owned file is never touched. The posture ladder performs the
|
|
352
|
+
* same swap as its `refreshed` state; this entry point exists for serve,
|
|
353
|
+
* which carries no posture step. Returns whether a swap happened. */
|
|
354
|
+
export function retireTlsIgnoreLine(ukpDirectory: string): boolean {
|
|
355
|
+
const gitignorePath = join(ukpDirectory, ".gitignore");
|
|
356
|
+
let content: string;
|
|
357
|
+
try {
|
|
358
|
+
content = readFileSync(gitignorePath, "utf8");
|
|
359
|
+
} catch {
|
|
360
|
+
return false; // no guard in place — nothing to retire
|
|
361
|
+
}
|
|
362
|
+
if (content !== UKP_SELF_IGNORE_WITH_TLS) return false;
|
|
363
|
+
swapUkpIgnoreTemplate(ukpDirectory);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
156
367
|
function ensureUkpSelfIgnore(ukpDirectory: string): void {
|
|
157
368
|
const gitignorePath = join(ukpDirectory, ".gitignore");
|
|
158
369
|
if (existsSync(gitignorePath)) return;
|
|
@@ -172,6 +383,10 @@ function createServiceManifest(
|
|
|
172
383
|
const ukpDirectory = join(folder, ".ukp");
|
|
173
384
|
const manifestPath = join(ukpDirectory, "service.toml");
|
|
174
385
|
const { name, nameSource, dependencies } = validateInitServiceOptions(folder, options);
|
|
386
|
+
// INV-21 admission gate: a pre-existing .ukp/ must hold nothing UKP does
|
|
387
|
+
// not own before init writes into it — operator-made junk fails loudly
|
|
388
|
+
// with zero residue (nothing has been written yet).
|
|
389
|
+
assertUkpResidents(ukpDirectory, { endpointName: name });
|
|
175
390
|
if (existsSync(manifestPath)) {
|
|
176
391
|
throw new Error(`Service Manifest already exists: ${manifestPath}`);
|
|
177
392
|
}
|