@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/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. The stderr
428
- // footnotes wrap at the terminal width there too — piped stderr keeps
429
- // the raw single lines (wrapLinesForTerminal's contract).
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. Create a workspace Client Config
16
- .ukp/client.toml at the workspace root:
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; -uu also lifts
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'.
@@ -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
- /** Self-ignore guard for `.ukp/` (O-020 door-family B-b): the folder holds
143
- * machine-local Service state (manifest, instance identity, TLS private
144
- * keys) that must never enter version control or folder sync. `*` inside
145
- * the tool-owned directory is the virtualenv / Python 3.13 venv / Meson
146
- * precedent — it never touches the user's repo-root `.gitignore`. An
147
- * existing file is never touched, so operators who pre-write their own
148
- * ignore policy keep it (e.g. tracking service.toml on purpose). */
149
- const UKP_SELF_IGNORE = [
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
  }