@catheadowl/ukp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,188 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { parse } from "smol-toml";
4
+ import { z } from "zod";
5
+ import { isExternalToolCapability, EXTERNAL_PROVIDER } from "./external-tool.ts";
6
+ import { isFileNativeCapability, normalizeFileNativeFlatKeys } from "./file-native.ts";
7
+
8
+ export const ENDPOINT_NAME = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
+
10
+ // `provider` is optional at the schema level: file-native capabilities
11
+ // (see file-native.ts, ADR 0016) default to `"file"` at load; every other
12
+ // capability fails fast with a declaration error when `provider` is missing.
13
+ const capabilitySchema = z.object({
14
+ provider: z.string().min(1).optional(),
15
+ config: z.record(z.string(), z.unknown()).optional(),
16
+ }).strict();
17
+
18
+ const endpointNameSchema = z.string().regex(ENDPOINT_NAME);
19
+
20
+ const dependencyKindSchema = z.enum(["authority", "context", "implementation", "evidence"]);
21
+
22
+ const dependencySchema = z.object({
23
+ endpoint: endpointNameSchema,
24
+ kind: dependencyKindSchema,
25
+ reason: z.string().min(1).optional(),
26
+ }).strict();
27
+
28
+ const dependenciesSchema = z.array(dependencySchema).min(1).superRefine((value, context) => {
29
+ const seen = new Set<string>();
30
+ for (const [index, dependency] of value.entries()) {
31
+ if (seen.has(dependency.endpoint)) {
32
+ context.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ message: `duplicate dependency '${dependency.endpoint}'`,
35
+ path: [index],
36
+ });
37
+ continue;
38
+ }
39
+ seen.add(dependency.endpoint);
40
+ }
41
+ });
42
+
43
+ const manifestSchema = z.object({
44
+ name: z.string().optional(),
45
+ description: z.string().min(1).optional(),
46
+ dependencies: dependenciesSchema.optional(),
47
+ capabilities: z.record(z.string(), capabilitySchema),
48
+ }).strict();
49
+
50
+ export type Manifest = z.infer<typeof manifestSchema>;
51
+ export type ManifestCapability = z.infer<typeof capabilitySchema>;
52
+ export type ManifestDependency = z.infer<typeof dependencySchema>;
53
+
54
+ export class ManifestError extends Error {
55
+ constructor(message: string, options?: ErrorOptions) {
56
+ super(message, options);
57
+ this.name = "ManifestError";
58
+ }
59
+ }
60
+
61
+ export function assertRestrictedToml(value: unknown, path = "root"): void {
62
+ if (value === null || value === undefined || value instanceof Date) {
63
+ throw new ManifestError(`${path}: unsupported TOML value`);
64
+ }
65
+ if (typeof value === "number") {
66
+ if (!Number.isSafeInteger(value)) {
67
+ throw new ManifestError(`${path}: only safe integer TOML values are supported`);
68
+ }
69
+ return;
70
+ }
71
+ if (typeof value === "string" || typeof value === "boolean") return;
72
+ if (typeof value === "bigint") {
73
+ throw new ManifestError(`${path}: integer exceeds JavaScript safe range`);
74
+ }
75
+ if (Array.isArray(value)) {
76
+ value.forEach((item, index) => assertRestrictedToml(item, `${path}[${index}]`));
77
+ return;
78
+ }
79
+ if (typeof value === "object") {
80
+ for (const [key, child] of Object.entries(value)) {
81
+ assertRestrictedToml(child, `${path}.${key}`);
82
+ }
83
+ return;
84
+ }
85
+ throw new ManifestError(`${path}: unsupported TOML value`);
86
+ }
87
+
88
+ function parseName(value: string, source: string): string {
89
+ if (!ENDPOINT_NAME.test(value)) {
90
+ throw new ManifestError(`${source}: invalid endpoint name '${value}'`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function isRecord(value: unknown): value is Record<string, unknown> {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+
99
+ function removeLegacyGetCapability(raw: unknown): unknown {
100
+ if (!isRecord(raw) || !isRecord(raw.capabilities) || !Object.hasOwn(raw.capabilities, "get")) {
101
+ return raw;
102
+ }
103
+
104
+ const { get: _ignoredLegacyGet, ...capabilities } = raw.capabilities;
105
+ return {
106
+ ...raw,
107
+ capabilities,
108
+ };
109
+ }
110
+
111
+ export interface LoadedManifest {
112
+ folder: string;
113
+ manifestPath: string;
114
+ manifest: Manifest;
115
+ effectiveName: string;
116
+ nameSource: "manifest" | "folder-name";
117
+ }
118
+
119
+ export function loadManifest(serviceFolder: string): LoadedManifest {
120
+ let folder: string;
121
+ try {
122
+ folder = realpathSync(serviceFolder);
123
+ if (!statSync(folder).isDirectory()) throw new Error("not a directory");
124
+ } catch (error) {
125
+ throw new ManifestError(`Service folder is not accessible: ${serviceFolder}`, { cause: error });
126
+ }
127
+
128
+ const manifestPath = join(folder, ".ukp", "service.toml");
129
+ let source: string;
130
+ try {
131
+ source = readFileSync(manifestPath, "utf8");
132
+ } catch (error) {
133
+ throw new ManifestError(`Service Manifest is not readable: ${manifestPath}`, { cause: error });
134
+ }
135
+
136
+ let raw: unknown;
137
+ try {
138
+ raw = parse(source);
139
+ } catch (error) {
140
+ throw new ManifestError(`Service Manifest TOML is invalid: ${manifestPath}`, { cause: error });
141
+ }
142
+ assertRestrictedToml(raw);
143
+
144
+ const withoutLegacyGet = removeLegacyGetCapability(raw);
145
+ // Flat file-native declaration keys (table-driven, ADR 0016) fold into
146
+ // `config` before the strict schema parse.
147
+ const normalized = normalizeFileNativeFlatKeys(withoutLegacyGet, (message) => {
148
+ throw new ManifestError(message);
149
+ });
150
+ const result = manifestSchema.safeParse(normalized);
151
+ if (!result.success) {
152
+ throw new ManifestError(`Service Manifest schema is invalid: ${result.error.message}`);
153
+ }
154
+ const manifest = result.data;
155
+ // A zero-declaration Manifest is valid (D-081): read/nav are derived
156
+ // file-native defaults for every registered local Service, so an empty
157
+ // [capabilities] table still yields a usable, diagnosable endpoint.
158
+
159
+ const nameSource = manifest.name === undefined ? "folder-name" : "manifest";
160
+ const effectiveName = parseName(manifest.name ?? basename(folder), nameSource);
161
+ if (manifest.dependencies?.some((dependency) => dependency.endpoint === effectiveName)) {
162
+ throw new ManifestError(`Service Manifest dependency cannot target the Service itself: '${effectiveName}'`);
163
+ }
164
+ // Capability-level provider defaults (ADR 0016 rule 1 / ADR-RG-003): a
165
+ // bare `[capabilities.<name>]` declaration means the UKP-native file
166
+ // provider for file-native capabilities, the external-tool base tier for
167
+ // external-tool capabilities. Every other capability fails fast at load
168
+ // when `provider` is missing (a typo must not degrade into a runtime
169
+ // "(none)" warning); an external-tool capability with a different provider
170
+ // is a hard error (the tier does not plugin alternative tools).
171
+ for (const [name, declaration] of Object.entries(manifest.capabilities)) {
172
+ if (declaration.provider === undefined) {
173
+ if (isFileNativeCapability(name)) {
174
+ manifest.capabilities[name] = { ...declaration, provider: "file" };
175
+ } else if (isExternalToolCapability(name)) {
176
+ manifest.capabilities[name] = { ...declaration, provider: EXTERNAL_PROVIDER };
177
+ } else {
178
+ throw new ManifestError(`[capabilities.${name}] must declare a provider`);
179
+ }
180
+ } else if (isExternalToolCapability(name) && declaration.provider !== EXTERNAL_PROVIDER) {
181
+ throw new ManifestError(
182
+ `[capabilities.${name}] provider must be '${EXTERNAL_PROVIDER}' or omitted `
183
+ + `(external-tool base tier: the declaration only overrides config)`,
184
+ );
185
+ }
186
+ }
187
+ return { folder, manifestPath, manifest, effectiveName, nameSource };
188
+ }
package/src/fslock.ts ADDED
@@ -0,0 +1,95 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ openSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { randomUUID } from "node:crypto";
11
+ import { hostname } from "node:os";
12
+ import { parse } from "smol-toml";
13
+
14
+ export class LockError extends Error {
15
+ constructor(message: string, options?: ErrorOptions) {
16
+ super(message, options);
17
+ this.name = "LockError";
18
+ }
19
+ }
20
+
21
+ export class LockBusyError extends LockError {
22
+ constructor(path: string) {
23
+ super(`lock_busy: ${path}`);
24
+ this.name = "LockBusyError";
25
+ }
26
+ }
27
+
28
+ function sleep(milliseconds: number): void {
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
30
+ }
31
+
32
+ function processExists(pid: number): boolean | undefined {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch (error) {
37
+ const code = (error as NodeJS.ErrnoException).code;
38
+ if (code === "ESRCH") return false;
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ function reclaimStaleLock(lockPath: string): boolean {
44
+ try {
45
+ const metadata = parse(readFileSync(lockPath, "utf8")) as Record<string, unknown>;
46
+ if (metadata.hostname !== hostname()) return false;
47
+ if (typeof metadata.pid !== "number" || !Number.isSafeInteger(metadata.pid)) return false;
48
+ if (typeof metadata.created_at !== "string") return false;
49
+ const createdAt = Date.parse(metadata.created_at);
50
+ if (!Number.isFinite(createdAt) || Date.now() - createdAt <= 10 * 60 * 1000) return false;
51
+ if (processExists(metadata.pid) !== false) return false;
52
+ unlinkSync(lockPath);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ // Write-discipline sibling lock: exclusive create with owner metadata, stale
60
+ // reclamation (same host, >10min old, dead pid), bounded retry.
61
+ export function acquireLock(lockPath: string): number {
62
+ const deadline = Date.now() + 5000;
63
+ while (true) {
64
+ try {
65
+ const descriptor = openSync(lockPath, "wx", 0o600);
66
+ try {
67
+ writeFileSync(
68
+ descriptor,
69
+ `pid = ${process.pid}\nhostname = ${JSON.stringify(hostname())}\ncreated_at = ${JSON.stringify(new Date().toISOString())}\ntoken = ${JSON.stringify(randomUUID())}\n`,
70
+ "utf8",
71
+ );
72
+ fsyncSync(descriptor);
73
+ return descriptor;
74
+ } catch (error) {
75
+ closeSync(descriptor);
76
+ if (existsSync(lockPath)) unlinkSync(lockPath);
77
+ throw error;
78
+ }
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "EEXIST" && reclaimStaleLock(lockPath)) {
81
+ continue;
82
+ }
83
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST" || Date.now() >= deadline) {
84
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new LockBusyError(lockPath);
85
+ throw new LockError(`cannot acquire lock: ${lockPath}`, { cause: error });
86
+ }
87
+ sleep(25);
88
+ }
89
+ }
90
+ }
91
+
92
+ export function releaseLock(lockPath: string, descriptor: number): void {
93
+ closeSync(descriptor);
94
+ if (existsSync(lockPath)) unlinkSync(lockPath);
95
+ }
@@ -0,0 +1,65 @@
1
+ import { isAbsolute, relative, win32 } from "node:path";
2
+
3
+ // Path-safety primitives (ADR 0023 / D-073, promoted spec
4
+ // `path-safety-primitives`): three pure, error-class-neutral checks shared
5
+ // by the file-facing capabilities. The module owns the *verdicts* only —
6
+ // failure semantics (usage error vs silent drop), root semantics
7
+ // (`allowRoot`), realpath fallbacks and existence checks are per-capability
8
+ // policy and stay at the call sites. Primitives never throw and never do
9
+ // I/O: containment receives already-realpathed paths; lexical checks are
10
+ // the advisory tier, realpath containment is the authority.
11
+
12
+ /** True for every input shape the file-facing capabilities reject as an
13
+ * endpoint-relative reference: absolute on either platform's view, a drive
14
+ * prefix (including drive-relative `C:foo`), or a UNC/`//` root. */
15
+ export function isAbsoluteShapedPath(reference: string): boolean {
16
+ return (
17
+ isAbsolute(reference)
18
+ || win32.isAbsolute(reference)
19
+ || /^[A-Za-z]:/.test(reference)
20
+ || reference.startsWith("//")
21
+ || reference.startsWith("\\\\")
22
+ );
23
+ }
24
+
25
+ export type EndpointRelativeSegments =
26
+ | { ok: true; segments: string[] }
27
+ | { ok: false; reason: "absolute" | "empty-segment" | "dot-segment" };
28
+
29
+ /** Segment-level validation of an endpoint-relative reference: rejects
30
+ * absolute shapes, empty segments (`a//b`), and `.`/`..` segments. Accepts
31
+ * both `/` and `\` separators; callers own separator normalization. The
32
+ * reason field lets each capability map its own error wording. */
33
+ export function splitEndpointRelativeSegments(reference: string): EndpointRelativeSegments {
34
+ if (isAbsoluteShapedPath(reference)) {
35
+ return { ok: false, reason: "absolute" };
36
+ }
37
+ const segments = reference.split(/[\\/]/);
38
+ if (segments.some((segment) => segment.length === 0)) {
39
+ return { ok: false, reason: "empty-segment" };
40
+ }
41
+ if (segments.some((segment) => segment === "." || segment === "..")) {
42
+ return { ok: false, reason: "dot-segment" };
43
+ }
44
+ return { ok: true, segments };
45
+ }
46
+
47
+ /** Realpath containment authority (ADR 0023): true when `targetReal` stays
48
+ * strictly inside `rootReal`, or is `rootReal` itself with `allowRoot`.
49
+ * Inputs must already be realpathed — lexical checks, realpath calls and
50
+ * stat checks are caller policy. Cross-drive/UNC↔drive `relative()` results
51
+ * come back absolute and are rejected explicitly. */
52
+ export function isInsideRealRoot(
53
+ rootReal: string,
54
+ targetReal: string,
55
+ options?: { allowRoot?: boolean },
56
+ ): boolean {
57
+ const rel = relative(rootReal, targetReal);
58
+ if (isAbsolute(rel) || win32.isAbsolute(rel)) return false;
59
+ // Plain `..` prefix guard — read's historical stance, kept verbatim for the
60
+ // zero-behavior-change acceptance line. Conservative: a sibling named
61
+ // `..literal` is rejected too; boundary refinement is a separate change.
62
+ if (rel.startsWith("..")) return false;
63
+ if (rel === "") return options?.allowRoot === true;
64
+ return true;
65
+ }