@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,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
|
+
}
|