@catheadowl/ukp 0.2.5 → 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.
@@ -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,6 +139,242 @@ function renderServiceManifest(options: { name?: string; description?: string; d
139
139
  return encoded.endsWith("\n") ? encoded : `${encoded}\n`;
140
140
  }
141
141
 
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 = [
185
+ "# UKP local Service state (manifest, identity, TLS keys) is machine-local:",
186
+ "# self-ignored to keep it out of version control and folder sync.",
187
+ "*",
188
+ "",
189
+ ].join("\n");
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
+
367
+ function ensureUkpSelfIgnore(ukpDirectory: string): void {
368
+ const gitignorePath = join(ukpDirectory, ".gitignore");
369
+ if (existsSync(gitignorePath)) return;
370
+ try {
371
+ writeFileSync(gitignorePath, UKP_SELF_IGNORE, { flag: "wx", mode: 0o644 });
372
+ } catch (error) {
373
+ // Concurrent writer installed the same guard — in place either way.
374
+ if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
375
+ }
376
+ }
377
+
142
378
  function createServiceManifest(
143
379
  currentDirectory: string,
144
380
  options: InitServiceOptions,
@@ -147,6 +383,10 @@ function createServiceManifest(
147
383
  const ukpDirectory = join(folder, ".ukp");
148
384
  const manifestPath = join(ukpDirectory, "service.toml");
149
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 });
150
390
  if (existsSync(manifestPath)) {
151
391
  throw new Error(`Service Manifest already exists: ${manifestPath}`);
152
392
  }
@@ -169,6 +409,8 @@ function createServiceManifest(
169
409
  throw error;
170
410
  }
171
411
 
412
+ ensureUkpSelfIgnore(ukpDirectory);
413
+
172
414
  return { folder, manifestPath, name, nameSource };
173
415
  }
174
416