@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,456 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { ENDPOINT_NAME, loadManifest, type ManifestCapability } from "../config/manifest.ts";
|
|
15
|
+
import { resolveFileNativeCapability, unsupportedFileNativeProviderMessage } from "../config/file-native.ts";
|
|
16
|
+
import { localPathOf, readRegistry } from "../registry.ts";
|
|
17
|
+
import { resolveScope } from "../scope.ts";
|
|
18
|
+
import { acquireLock, LockBusyError, releaseLock } from "../fslock.ts";
|
|
19
|
+
|
|
20
|
+
// The slug profile (1–63 chars, strict lowercase ASCII slug) shares the
|
|
21
|
+
// endpoint-name grammar, so the Manifest pattern is reused deliberately.
|
|
22
|
+
export const PROPOSE_SLUG = ENDPOINT_NAME;
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_PROPOSE_FOLDER = "inbox";
|
|
25
|
+
|
|
26
|
+
// Service-maintained frontmatter keys: always owned by the Service side of
|
|
27
|
+
// the upsert; never accepted from submitted content (injected values are
|
|
28
|
+
// stripped — see spec/file-provider.md, W1 decision).
|
|
29
|
+
const SERVICE_KEYS = new Set(["id", "status", "revision", "created", "updated"]);
|
|
30
|
+
|
|
31
|
+
export type ProposeStatus = "created" | "unchanged" | "updated";
|
|
32
|
+
|
|
33
|
+
export interface ProposeResult {
|
|
34
|
+
id: string;
|
|
35
|
+
status: ProposeStatus;
|
|
36
|
+
revision: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ProposeProviderError extends Error {
|
|
40
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
41
|
+
super(message, options);
|
|
42
|
+
this.name = "ProposeProviderError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ProposeBusyError extends ProposeProviderError {
|
|
47
|
+
constructor(id: string) {
|
|
48
|
+
// No provider location on any client-facing surface (probe 20260906).
|
|
49
|
+
super(`proposal '${id}' is locked by another submission; retry shortly`);
|
|
50
|
+
this.name = "ProposeBusyError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertProposeSlug(id: string): void {
|
|
55
|
+
if (!PROPOSE_SLUG.test(id)) {
|
|
56
|
+
throw new ProposeProviderError(
|
|
57
|
+
`invalid proposal id '${id}': expected 1-63 lowercase ASCII slug characters ([a-z0-9-])`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolves the file-provider folder for a propose capability declaration.
|
|
63
|
+
// The folder is Service-owned config, relative to the Service folder.
|
|
64
|
+
export function resolveProposeFolder(serviceFolder: string, capability: ManifestCapability): string {
|
|
65
|
+
if (capability.provider !== "file") {
|
|
66
|
+
throw new ProposeProviderError(unsupportedFileNativeProviderMessage("propose", capability.provider));
|
|
67
|
+
}
|
|
68
|
+
const rawFolder = capability.config?.folder;
|
|
69
|
+
let folder = DEFAULT_PROPOSE_FOLDER;
|
|
70
|
+
if (rawFolder !== undefined) {
|
|
71
|
+
if (typeof rawFolder !== "string" || rawFolder.length === 0) {
|
|
72
|
+
throw new ProposeProviderError("propose config 'folder' must be a non-empty string");
|
|
73
|
+
}
|
|
74
|
+
if (isAbsolute(rawFolder) || rawFolder.includes("\\") || rawFolder.includes(":")) {
|
|
75
|
+
throw new ProposeProviderError(
|
|
76
|
+
`propose config 'folder' must be a relative path inside the Service folder: ${rawFolder}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const segments = rawFolder.split("/");
|
|
80
|
+
// Segment charset = POSIX Portable Filename Character Set
|
|
81
|
+
// (A-Z a-z 0-9 . _ -) — one cited standard, deliberately no extra
|
|
82
|
+
// structural guards: a closed accepted set converges in one rename
|
|
83
|
+
// (the error message carries the full standard), and keeps the
|
|
84
|
+
// diagnose prompt one line. The folder is Service-owned config
|
|
85
|
+
// (trust domain); this check is lexical, not an identity profile.
|
|
86
|
+
if (
|
|
87
|
+
segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")
|
|
88
|
+
|| !segments.every((segment) => /^[A-Za-z0-9._-]+$/.test(segment))
|
|
89
|
+
) {
|
|
90
|
+
throw new ProposeProviderError(
|
|
91
|
+
`propose config 'folder' has unsafe path segments (each segment must use POSIX portable filename characters A-Z a-z 0-9 . _ -): ${rawFolder}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
folder = segments.join(sep);
|
|
95
|
+
}
|
|
96
|
+
return join(serviceFolder, folder);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ParsedDocument {
|
|
100
|
+
submitterLines: string[];
|
|
101
|
+
body: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function unquote(value: string): string {
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
if (
|
|
107
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2)
|
|
108
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
|
109
|
+
) {
|
|
110
|
+
return trimmed.slice(1, -1);
|
|
111
|
+
}
|
|
112
|
+
return trimmed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parses a proposal document. Submitted content may carry a frontmatter
|
|
116
|
+
// block; service-maintained keys are stripped (the submission never owns
|
|
117
|
+
// them), submitter-owned display keys pass through verbatim.
|
|
118
|
+
function parseDocument(content: string): ParsedDocument {
|
|
119
|
+
const lines = content.split("\n");
|
|
120
|
+
if (lines[0] === undefined || lines[0].trim() !== "---") {
|
|
121
|
+
return { submitterLines: [], body: content };
|
|
122
|
+
}
|
|
123
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
124
|
+
if (closing < 0) {
|
|
125
|
+
return { submitterLines: [], body: content };
|
|
126
|
+
}
|
|
127
|
+
const submitterLines: string[] = [];
|
|
128
|
+
for (const line of lines.slice(1, closing)) {
|
|
129
|
+
const key = line.split(":", 1)[0]?.trim() ?? "";
|
|
130
|
+
if (key !== "" && SERVICE_KEYS.has(key)) continue;
|
|
131
|
+
submitterLines.push(line);
|
|
132
|
+
}
|
|
133
|
+
const body = lines.slice(closing + 1).join("\n");
|
|
134
|
+
return { submitterLines, body };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseStoredDocument(content: string): { revision: number; created: string; document: ParsedDocument } {
|
|
138
|
+
const lines = content.split("\n");
|
|
139
|
+
if (lines[0]?.trim() !== "---") {
|
|
140
|
+
throw new ProposeProviderError("stored proposal is missing its frontmatter block");
|
|
141
|
+
}
|
|
142
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
143
|
+
if (closing < 0) {
|
|
144
|
+
throw new ProposeProviderError("stored proposal has an unterminated frontmatter block");
|
|
145
|
+
}
|
|
146
|
+
let revision: number | undefined;
|
|
147
|
+
let created: string | undefined;
|
|
148
|
+
const submitterLines: string[] = [];
|
|
149
|
+
for (const line of lines.slice(1, closing)) {
|
|
150
|
+
const separator = line.indexOf(":");
|
|
151
|
+
if (separator < 0) continue;
|
|
152
|
+
const key = line.slice(0, separator).trim();
|
|
153
|
+
const value = unquote(line.slice(separator + 1));
|
|
154
|
+
if (key === "revision") {
|
|
155
|
+
const parsed = Number(value);
|
|
156
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
157
|
+
throw new ProposeProviderError(`stored proposal has an invalid revision: ${value}`);
|
|
158
|
+
}
|
|
159
|
+
revision = parsed;
|
|
160
|
+
} else if (key === "created") {
|
|
161
|
+
created = value;
|
|
162
|
+
} else if (key !== "" && !SERVICE_KEYS.has(key)) {
|
|
163
|
+
submitterLines.push(line);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (revision === undefined || created === undefined) {
|
|
167
|
+
throw new ProposeProviderError("stored proposal is missing service-maintained frontmatter fields");
|
|
168
|
+
}
|
|
169
|
+
return { revision, created, document: { submitterLines, body: lines.slice(closing + 1).join("\n") } };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderDocument(
|
|
173
|
+
id: string,
|
|
174
|
+
submitterLines: readonly string[],
|
|
175
|
+
revision: number,
|
|
176
|
+
created: string,
|
|
177
|
+
updated: string,
|
|
178
|
+
body: string,
|
|
179
|
+
): string {
|
|
180
|
+
const frontmatter = [
|
|
181
|
+
"---",
|
|
182
|
+
`id: ${id}`,
|
|
183
|
+
...submitterLines,
|
|
184
|
+
"status: proposed",
|
|
185
|
+
`revision: ${revision}`,
|
|
186
|
+
`created: ${created}`,
|
|
187
|
+
`updated: ${updated}`,
|
|
188
|
+
"---",
|
|
189
|
+
];
|
|
190
|
+
return `${frontmatter.join("\n")}\n${body}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sameSubmission(left: ParsedDocument, right: ParsedDocument): boolean {
|
|
194
|
+
return left.body === right.body && left.submitterLines.join("\n") === right.submitterLines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function writeAtomic(targetPath: string, content: string): void {
|
|
198
|
+
const tempPath = `${targetPath}.tmp.${randomUUID()}`;
|
|
199
|
+
try {
|
|
200
|
+
const descriptor = openSync(tempPath, "wx", 0o600);
|
|
201
|
+
try {
|
|
202
|
+
writeFileSync(descriptor, content, "utf8");
|
|
203
|
+
fsyncSync(descriptor);
|
|
204
|
+
} finally {
|
|
205
|
+
closeSync(descriptor);
|
|
206
|
+
}
|
|
207
|
+
renameSync(tempPath, targetPath);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ProposeUpsertContext {
|
|
215
|
+
now?: () => Date;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The file-provider upsert: sibling lock, in-lock re-read, three-state
|
|
219
|
+
// classification, temp-file + atomic replace discipline.
|
|
220
|
+
export function proposeUpsert(
|
|
221
|
+
serviceFolder: string,
|
|
222
|
+
capability: ManifestCapability,
|
|
223
|
+
id: string,
|
|
224
|
+
content: string,
|
|
225
|
+
context: ProposeUpsertContext = {},
|
|
226
|
+
): ProposeResult {
|
|
227
|
+
assertProposeSlug(id);
|
|
228
|
+
const folder = resolveProposeFolder(serviceFolder, capability);
|
|
229
|
+
mkdirSync(folder, { recursive: true });
|
|
230
|
+
const targetPath = join(folder, `${id}.md`);
|
|
231
|
+
const lockPath = `${targetPath}.lock`;
|
|
232
|
+
|
|
233
|
+
let lockDescriptor: number;
|
|
234
|
+
try {
|
|
235
|
+
lockDescriptor = acquireLock(lockPath);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof LockBusyError) throw new ProposeBusyError(id);
|
|
238
|
+
throw new ProposeProviderError(`cannot acquire proposal lock: ${lockPath}`, { cause: error });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const submission = parseDocument(content);
|
|
243
|
+
const timestamp = (context.now ?? (() => new Date()))().toISOString();
|
|
244
|
+
|
|
245
|
+
if (!existsSync(targetPath)) {
|
|
246
|
+
const document = renderDocument(id, submission.submitterLines, 1, timestamp, timestamp, submission.body);
|
|
247
|
+
writeAtomic(targetPath, document);
|
|
248
|
+
return { id, status: "created", revision: 1 };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const stored = parseStoredDocument(readFileSync(targetPath, "utf8"));
|
|
252
|
+
if (sameSubmission(submission, stored.document)) {
|
|
253
|
+
return { id, status: "unchanged", revision: stored.revision };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const document = renderDocument(
|
|
257
|
+
id,
|
|
258
|
+
submission.submitterLines,
|
|
259
|
+
stored.revision + 1,
|
|
260
|
+
stored.created,
|
|
261
|
+
timestamp,
|
|
262
|
+
submission.body,
|
|
263
|
+
);
|
|
264
|
+
writeAtomic(targetPath, document);
|
|
265
|
+
return { id, status: "updated", revision: stored.revision + 1 };
|
|
266
|
+
} finally {
|
|
267
|
+
releaseLock(lockPath, lockDescriptor);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface ProposeRequest {
|
|
272
|
+
endpoint: string;
|
|
273
|
+
id?: string;
|
|
274
|
+
file?: string;
|
|
275
|
+
json: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ProposeContext {
|
|
279
|
+
currentDirectory: string;
|
|
280
|
+
registryPath: string;
|
|
281
|
+
now?: () => Date;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** ADR 0021 error classification — factual messages; the `ukp propose:`
|
|
285
|
+
* prefix and exit codes are adapter renderings. */
|
|
286
|
+
export type ProposeErrorClass =
|
|
287
|
+
| "no-endpoint"
|
|
288
|
+
| "endpoint-name-mismatch"
|
|
289
|
+
| "capability-undeclared"
|
|
290
|
+
| "submission-file-unreadable"
|
|
291
|
+
| "provider-unsupported";
|
|
292
|
+
|
|
293
|
+
export interface ProposeFailure {
|
|
294
|
+
errorClass: ProposeErrorClass;
|
|
295
|
+
message: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** ADR 0021 structured outcome. The upsert core (`ProposeResult`) was
|
|
299
|
+
* already structured; this wraps the endpoint/capability/file prelude. */
|
|
300
|
+
export type ProposeOutcome =
|
|
301
|
+
| { ok: true; result: ProposeResult }
|
|
302
|
+
| { ok: false; failure: ProposeFailure };
|
|
303
|
+
|
|
304
|
+
export class ProposeUsageError extends Error {
|
|
305
|
+
constructor(message: string) {
|
|
306
|
+
super(message);
|
|
307
|
+
this.name = "ProposeUsageError";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Derives the default proposal id from a --file basename (extension
|
|
312
|
+
// stripped). The derived value still has to satisfy the slug profile.
|
|
313
|
+
export function deriveIdFromFileName(filePath: string): string {
|
|
314
|
+
const name = basename(filePath);
|
|
315
|
+
const dotIndex = name.lastIndexOf(".");
|
|
316
|
+
const id = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
|
317
|
+
if (!PROPOSE_SLUG.test(id)) {
|
|
318
|
+
throw new ProposeUsageError(
|
|
319
|
+
`proposal id derived from '${name}' is not a valid slug (1-63 lowercase ASCII [a-z0-9-]); pass --id explicitly`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return id;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function renderProposeHuman(result: ProposeResult): string {
|
|
326
|
+
// Probe 20260906-promoted shape: no provider location on the human
|
|
327
|
+
// surface; the --json envelope carries the endpoint name instead.
|
|
328
|
+
return `proposal ${result.id} ${result.status} (revision ${result.revision})\n`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The `ukp.propose.v1` envelope object — single source for the CLI
|
|
332
|
+
* `--json` rendering and the serve `PUT /v1/propose/{id}` response (W8):
|
|
333
|
+
* the wire response IS the local envelope. */
|
|
334
|
+
export function proposeEnvelope(endpoint: string, result: ProposeResult): {
|
|
335
|
+
schema: string;
|
|
336
|
+
command: string;
|
|
337
|
+
capability: string;
|
|
338
|
+
endpoint: string;
|
|
339
|
+
id: string;
|
|
340
|
+
status: ProposeStatus;
|
|
341
|
+
revision: number;
|
|
342
|
+
} {
|
|
343
|
+
return {
|
|
344
|
+
schema: "ukp.propose.v1",
|
|
345
|
+
command: "propose",
|
|
346
|
+
capability: "propose",
|
|
347
|
+
endpoint,
|
|
348
|
+
id: result.id,
|
|
349
|
+
status: result.status,
|
|
350
|
+
revision: result.revision,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function renderProposeJson(endpoint: string, result: ProposeResult): string {
|
|
355
|
+
return `${JSON.stringify(proposeEnvelope(endpoint, result), null, 2)}\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Resolves the proposal id for a request (explicit `--id` or derived from
|
|
359
|
+
* the --file basename) — shared by the local run and the remote branch
|
|
360
|
+
* (W8). Usage violations throw `ProposeUsageError`. */
|
|
361
|
+
export function proposalIdOf(request: Pick<ProposeRequest, "id" | "file">): string {
|
|
362
|
+
// Single stable channel (decision 2026-09-06): --file is the only content
|
|
363
|
+
// source. A stdin channel would need unreliable isTTY-based selection
|
|
364
|
+
// (agent harnesses spawn with piped stdin), and the canonical propose
|
|
365
|
+
// loop is read → edit local file → resubmit anyway.
|
|
366
|
+
if (request.file === undefined) {
|
|
367
|
+
throw new ProposeUsageError("propose requires --file <path> (the proposal content source)");
|
|
368
|
+
}
|
|
369
|
+
const id = request.id ?? deriveIdFromFileName(request.file);
|
|
370
|
+
if (!PROPOSE_SLUG.test(id)) {
|
|
371
|
+
throw new ProposeUsageError(
|
|
372
|
+
`invalid proposal id '${id}': expected 1-63 lowercase ASCII slug characters ([a-z0-9-])`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return id;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Reads the --file submission content relative to the caller's working
|
|
379
|
+
* directory — shared by the local run and the remote branch (W8); an
|
|
380
|
+
* unreadable file classifies as submission-file-unreadable. */
|
|
381
|
+
export function readSubmissionFile(
|
|
382
|
+
file: string,
|
|
383
|
+
currentDirectory: string,
|
|
384
|
+
): { content: string } | { failure: ProposeFailure } {
|
|
385
|
+
const filePath = resolve(currentDirectory, file);
|
|
386
|
+
try {
|
|
387
|
+
return { content: readFileSync(filePath, "utf8") };
|
|
388
|
+
} catch (error) {
|
|
389
|
+
const detail = error instanceof Error && "code" in error && error.code === "ENOENT"
|
|
390
|
+
? "file not found"
|
|
391
|
+
: error instanceof Error ? error.message : String(error);
|
|
392
|
+
return {
|
|
393
|
+
failure: {
|
|
394
|
+
errorClass: "submission-file-unreadable",
|
|
395
|
+
message: `cannot read --file '${file}': ${detail}`,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** ADR 0021 core entry: validates the prelude (id slug, scope, capability
|
|
402
|
+
* declaration, submission file) and runs the idempotent upsert. Usage
|
|
403
|
+
* violations throw `ProposeUsageError`; provider/config violations throw
|
|
404
|
+
* `ProposeProviderError`; scope/manifest failures throw their typed errors —
|
|
405
|
+
* all mapped by the surface adapter. The `json` request flag is a surface
|
|
406
|
+
* concern: the outcome is always structured. */
|
|
407
|
+
export function runPropose(request: ProposeRequest, context: ProposeContext): ProposeOutcome {
|
|
408
|
+
const id = proposalIdOf(request);
|
|
409
|
+
|
|
410
|
+
const registry = readRegistry(context.registryPath);
|
|
411
|
+
const scope = resolveScope({
|
|
412
|
+
currentDirectory: context.currentDirectory,
|
|
413
|
+
registry,
|
|
414
|
+
explicitEndpoints: [request.endpoint],
|
|
415
|
+
global: false,
|
|
416
|
+
});
|
|
417
|
+
const [binding] = scope.bindings;
|
|
418
|
+
if (!binding) {
|
|
419
|
+
return { ok: false, failure: { errorClass: "no-endpoint", message: "no endpoint selected" } };
|
|
420
|
+
}
|
|
421
|
+
// Remote bindings never reach this local path in the CLI composition (the
|
|
422
|
+
// command adapter routes them to the remote branch, W8); localPathOf
|
|
423
|
+
// throwing for a remote binding is the backstop (W6 nav/rg precedent).
|
|
424
|
+
|
|
425
|
+
const service = loadManifest(localPathOf(binding));
|
|
426
|
+
if (service.effectiveName !== binding.name) {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
failure: {
|
|
430
|
+
errorClass: "endpoint-name-mismatch",
|
|
431
|
+
message: `endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Propose is file-native but write-side (ADR 0016): an explicit
|
|
437
|
+
// declaration is mandatory; resolution goes through the shared table.
|
|
438
|
+
const resolved = resolveFileNativeCapability(service.manifest, "propose");
|
|
439
|
+
if (!resolved) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
failure: {
|
|
443
|
+
errorClass: "capability-undeclared",
|
|
444
|
+
message: `endpoint '${binding.name}' does not declare the propose capability`,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const submission = readSubmissionFile(request.file!, context.currentDirectory);
|
|
450
|
+
if ("failure" in submission) {
|
|
451
|
+
return { ok: false, failure: submission.failure };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const result = proposeUpsert(service.folder, resolved.capability, id, submission.content, { now: context.now });
|
|
455
|
+
return { ok: true, result };
|
|
456
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared QMD provider helpers for UKP adapters.
|
|
3
|
+
*
|
|
4
|
+
* QMD owns collection, index, ranking, ignore, and config. UKP only locates the
|
|
5
|
+
* executable, invokes provider-owned commands, and normalizes output to UKP's
|
|
6
|
+
* output discipline. These helpers are the single place for that plumbing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A bare docid handoff key is a 6-hex content fingerprint — the first 6 chars of
|
|
11
|
+
* QMD's content SHA-256 — optionally carrying a `:line` suffix (ADR 0011). QMD
|
|
12
|
+
* emits the fingerprint with a leading `#` (`#abc123`); `search` strips it on
|
|
13
|
+
* the surface so the token is verbatim-copyable, and `read` re-adds it before
|
|
14
|
+
* constructing `qmd get #docid[:line]`. A leading `#` is a shell comment and
|
|
15
|
+
* would silently truncate the reference, so it never appears on the
|
|
16
|
+
* UKP surface.
|
|
17
|
+
*/
|
|
18
|
+
export function isBareDocidReference(reference: string): boolean {
|
|
19
|
+
return /^[a-f0-9]{6}(:\d+)?$/.test(reference);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Strip QMD's leading `#` from a docid token (`#abc123` → `abc123`). */
|
|
23
|
+
export function stripDocidHash(docid: string): string {
|
|
24
|
+
return docid.startsWith("#") ? docid.slice(1) : docid;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Test whether a token is a bare 6-hex QMD docid body (`[a-f0-9]{6}`, ADR 0011). */
|
|
28
|
+
export function isDocidBody(token: string): boolean {
|
|
29
|
+
return /^[a-f0-9]{6}$/.test(token);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the `qmd get` argument for a reference.
|
|
34
|
+
*
|
|
35
|
+
* A bare docid handoff key has its `#` re-added so QMD resolves it by content
|
|
36
|
+
* fingerprint exactly. Any other reference — a weak name/path suffix or a
|
|
37
|
+
* `qmd://` provider reference — is forwarded verbatim. An optional line range is
|
|
38
|
+
* appended as `:start[:count]`, matching QMD's `path:from:count` suffix.
|
|
39
|
+
*/
|
|
40
|
+
export function toQmdGetArgument(
|
|
41
|
+
reference: string,
|
|
42
|
+
lines?: { start: number; count?: number },
|
|
43
|
+
): string {
|
|
44
|
+
const base = isBareDocidReference(reference) ? `#${reference}` : reference;
|
|
45
|
+
if (!lines) return base;
|
|
46
|
+
return lines.count === undefined
|
|
47
|
+
? `${base}:${lines.start}`
|
|
48
|
+
: `${base}:${lines.start}:${lines.count}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function defaultQmdCommand(): string[] | undefined {
|
|
52
|
+
const executable = Bun.which("qmd") ?? Bun.which("qmd.ps1") ?? Bun.which("qmd.cmd");
|
|
53
|
+
if (!executable) return undefined;
|
|
54
|
+
const lower = executable.toLowerCase();
|
|
55
|
+
if (lower.endsWith(".ps1")) {
|
|
56
|
+
return [Bun.which("powershell.exe") ?? "powershell.exe", "-NoProfile", "-File", executable];
|
|
57
|
+
}
|
|
58
|
+
// npm's global bin shims on Windows (qmd.cmd) are batch scripts,
|
|
59
|
+
// not executable images — CreateProcess cannot start them directly and
|
|
60
|
+
// spawnSync returns result.error. Route them through cmd.exe, same wrapper
|
|
61
|
+
// precedent as the .ps1 branch above. The wrapper prefix is detected by
|
|
62
|
+
// buildQmdInvocation, which re-assembles the whole provider call as one
|
|
63
|
+
// cmd-escaped /c payload — callers must never append raw arguments after a
|
|
64
|
+
// cmd.exe wrapper themselves (cmd re-parses the joined command line: an
|
|
65
|
+
// unquoted `&` in a search query would split the command).
|
|
66
|
+
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
|
|
67
|
+
return [Bun.which("cmd.exe") ?? "cmd.exe", "/d", "/s", "/c", executable];
|
|
68
|
+
}
|
|
69
|
+
return [executable];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function envTimeoutMs(name: string, fallbackMs: number): number {
|
|
73
|
+
const raw = process.env[name];
|
|
74
|
+
if (raw !== undefined && /^\d+$/.test(raw) && Number(raw) >= 1_000) return Number(raw);
|
|
75
|
+
return fallbackMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Zero-output hang guard (miss-path-scan performance audit, 2026-09-10): every
|
|
80
|
+
* provider spawn must carry a timeout so a hung provider lands in a classified
|
|
81
|
+
* failure instead of silence — agent consumers have zero tolerance for
|
|
82
|
+
* unresponsive commands (BB-004/BB-006 measured two 120s timeouts → detour).
|
|
83
|
+
* `UKP_PROVIDER_TIMEOUT_MS` overrides for tests and slow machines; minimum
|
|
84
|
+
* 1s, default 60s. spawnSync kills with SIGTERM on expiry — callers classify
|
|
85
|
+
* `signal === "SIGTERM"` alongside their failure branches.
|
|
86
|
+
*/
|
|
87
|
+
export function providerTimeoutMs(): number {
|
|
88
|
+
return envTimeoutMs("UKP_PROVIDER_TIMEOUT_MS", 60_000);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Update runs `qmd update`, which may legitimately index a large corpus —
|
|
93
|
+
* its ceiling is correspondingly higher (default 600s, overridable via
|
|
94
|
+
* `UKP_UPDATE_TIMEOUT_MS`).
|
|
95
|
+
*/
|
|
96
|
+
export function updateTimeoutMs(): number {
|
|
97
|
+
return envTimeoutMs("UKP_UPDATE_TIMEOUT_MS", 600_000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Detect a defaultQmdCommand cmd.exe wrapper prefix (…, "/c", <exe>). */
|
|
101
|
+
function isCmdWrapper(command: readonly string[]): boolean {
|
|
102
|
+
if (command.length < 5) return false;
|
|
103
|
+
const first = command[0]!.toLowerCase();
|
|
104
|
+
return (first.endsWith("cmd.exe") || first === "cmd")
|
|
105
|
+
&& command.slice(1, -1).includes("/c");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the spawn file/args (and Windows quoting mode) for one provider
|
|
110
|
+
* invocation.
|
|
111
|
+
*
|
|
112
|
+
* For a plain command the provider arguments are appended verbatim. For a
|
|
113
|
+
* cmd.exe wrapper the whole call after `/c` must become ONE argv
|
|
114
|
+
* entry carrying the canonical cmd pattern: every element quoted with
|
|
115
|
+
* internal quotes doubled, the whole payload wrapped in one extra outer
|
|
116
|
+
* quote pair, spawned with `windowsVerbatimArguments: true` so the runtime
|
|
117
|
+
* does not re-quote/re-escape it. With `/s`, cmd strips exactly the first and
|
|
118
|
+
* last (outer) quote, leaving `"exe" "arg1" "arg2"…` — metacharacters
|
|
119
|
+
* (`& | < > ^ ( )`), spaces, and quotes in arbitrary arguments (search
|
|
120
|
+
* queries carry user text) are neutralized. Verified empirically against the
|
|
121
|
+
* real npm shim: without verbatim mode Bun re-quotes the payload and cmd
|
|
122
|
+
* sees a mangled command name.
|
|
123
|
+
*
|
|
124
|
+
* Known residual: cmd expands `%VAR%` for existing variables even inside
|
|
125
|
+
* double quotes; a literal percent query on a machine defining that variable
|
|
126
|
+
* is altered — accepted edge, no reliable cmd escaping exists for it.
|
|
127
|
+
*/
|
|
128
|
+
export function buildQmdInvocation(
|
|
129
|
+
command: readonly string[],
|
|
130
|
+
providerArgs: readonly string[],
|
|
131
|
+
): { file: string; args: string[]; verbatim: boolean } {
|
|
132
|
+
if (!isCmdWrapper(command)) {
|
|
133
|
+
return { file: command[0]!, args: [...command.slice(1), ...providerArgs], verbatim: false };
|
|
134
|
+
}
|
|
135
|
+
const cIndex = command.lastIndexOf("/c");
|
|
136
|
+
const inner = [command[cIndex + 1]!, ...providerArgs]
|
|
137
|
+
.map((part) => `"${part.replace(/"/g, '""')}"`)
|
|
138
|
+
.join(" ");
|
|
139
|
+
return {
|
|
140
|
+
file: command[0]!,
|
|
141
|
+
args: [...command.slice(1, cIndex + 1), `"${inner}"`],
|
|
142
|
+
verbatim: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Strip QMD `get` provider header so `ukp read` stdout starts at the body.
|
|
148
|
+
*
|
|
149
|
+
* QMD prints a provider location header (first line, `qmd://<collection>/<path>`),
|
|
150
|
+
* zero or more metadata lines, a `---` separator, then the body. The adapter
|
|
151
|
+
* strips that prefix only when the leading region really is a QMD header — the
|
|
152
|
+
* first non-empty line before the separator starts with `qmd://`. A body-only
|
|
153
|
+
* output that merely contains a `---` divider is left intact. CRLF line endings
|
|
154
|
+
* are normalized to LF so `ukp read` stdout is line-ending-stable.
|
|
155
|
+
*/
|
|
156
|
+
export function stripQmdHeader(output: string): string {
|
|
157
|
+
const normalized = output.replace(/\r\n/g, "\n");
|
|
158
|
+
const lines = normalized.split("\n");
|
|
159
|
+
let sepIndex = -1;
|
|
160
|
+
for (let i = 0; i < lines.length; i++) {
|
|
161
|
+
if (lines[i]!.trim() === "---") {
|
|
162
|
+
sepIndex = i;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (sepIndex <= 0) return normalized;
|
|
167
|
+
const firstLine = lines.slice(0, sepIndex).find((line) => line.trim() !== "");
|
|
168
|
+
if (!firstLine?.trimStart().startsWith("qmd://")) return normalized;
|
|
169
|
+
let start = sepIndex + 1;
|
|
170
|
+
while (start < lines.length && lines[start]!.trim() === "") start++;
|
|
171
|
+
const body = lines.slice(start).join("\n").replace(/\s+$/, "");
|
|
172
|
+
if (!body) return "";
|
|
173
|
+
return `${body}\n`;
|
|
174
|
+
}
|