tgo-wiki 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/CHANGELOG.md +32 -0
- package/README.md +255 -0
- package/docs/mcp-usage.md +631 -0
- package/docs/v0-acceptance.md +105 -0
- package/docs/v0-delivery-checklist.md +57 -0
- package/docs/v1-acceptance.md +39 -0
- package/docs/v2-acceptance.md +165 -0
- package/package.json +69 -0
- package/packages/core/src/config/config-loader.ts +109 -0
- package/packages/core/src/config/defaults.ts +74 -0
- package/packages/core/src/config/workspace-resolver.ts +40 -0
- package/packages/core/src/documents/command-document-parser.ts +206 -0
- package/packages/core/src/documents/document-id.ts +26 -0
- package/packages/core/src/documents/document-parser-registry.ts +126 -0
- package/packages/core/src/documents/document-service.ts +656 -0
- package/packages/core/src/documents/document-store.ts +132 -0
- package/packages/core/src/documents/document-types.ts +33 -0
- package/packages/core/src/documents/pdf-text-parser.ts +35 -0
- package/packages/core/src/documents/text-markdown-parser.ts +50 -0
- package/packages/core/src/errors.ts +46 -0
- package/packages/core/src/git/git-service.ts +68 -0
- package/packages/core/src/index.ts +38 -0
- package/packages/core/src/markdown/markdown-scanner.ts +90 -0
- package/packages/core/src/permissions/permission-service.ts +50 -0
- package/packages/core/src/publish/publish-service.ts +142 -0
- package/packages/core/src/result.ts +13 -0
- package/packages/core/src/services/session-workflow-service.ts +493 -0
- package/packages/core/src/services/wiki-service.ts +119 -0
- package/packages/core/src/services/workspace-service.ts +223 -0
- package/packages/core/src/session/session-id.ts +14 -0
- package/packages/core/src/session/session-service.ts +77 -0
- package/packages/core/src/session/session-store.ts +91 -0
- package/packages/core/src/session/session-types.ts +17 -0
- package/packages/core/src/sources/source-id.ts +19 -0
- package/packages/core/src/sources/source-paths.ts +15 -0
- package/packages/core/src/sources/source-service.ts +416 -0
- package/packages/core/src/sources/source-types.ts +77 -0
- package/packages/core/src/sources/source-validator.ts +132 -0
- package/packages/core/src/sources/source-writer.ts +419 -0
- package/packages/core/src/validation/frontmatter-validator.ts +128 -0
- package/packages/core/src/validation/link-validator.ts +55 -0
- package/packages/core/src/validation/path-validator.ts +65 -0
- package/packages/core/src/validation/source-reference-validator.ts +191 -0
- package/packages/core/src/validation/validation-service.ts +106 -0
- package/packages/core/src/vfs/vfs-command-parser.ts +69 -0
- package/packages/core/src/vfs/vfs-service.ts +498 -0
- package/packages/core/src/web/html-to-markdown.ts +144 -0
- package/packages/core/src/web/static-web-fetcher.ts +537 -0
- package/packages/core/src/web/web-id.ts +26 -0
- package/packages/core/src/web/web-ingestion-service.ts +335 -0
- package/packages/core/src/web/web-paths.ts +6 -0
- package/packages/core/src/web/web-types.ts +33 -0
- package/packages/server/src/cli.ts +56 -0
- package/packages/server/src/context.ts +7 -0
- package/packages/server/src/index.ts +2 -0
- package/packages/server/src/mcp-server.ts +111 -0
- package/packages/server/src/schemas/documents.ts +17 -0
- package/packages/server/src/schemas/read.ts +16 -0
- package/packages/server/src/schemas/session.ts +31 -0
- package/packages/server/src/schemas/sources.ts +12 -0
- package/packages/server/src/schemas/web.ts +23 -0
- package/packages/server/src/tools/document-tools.ts +46 -0
- package/packages/server/src/tools/publish-tools.ts +33 -0
- package/packages/server/src/tools/read-tools.ts +52 -0
- package/packages/server/src/tools/response.ts +24 -0
- package/packages/server/src/tools/session-tools.ts +100 -0
- package/packages/server/src/tools/source-tools.ts +32 -0
- package/packages/server/src/tools/web-tools.ts +26 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { lstat, link, mkdir, realpath, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { WikiError } from "../errors.js";
|
|
5
|
+
import { assertValidSourceId } from "./source-id.js";
|
|
6
|
+
import { sourceDirectory, sourceMetadataPath, sourceRawMarkdownPath } from "./source-paths.js";
|
|
7
|
+
|
|
8
|
+
export type SourceWriterAsset = {
|
|
9
|
+
path: string;
|
|
10
|
+
content: Buffer | Uint8Array | string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PublishSourceMode = "create" | "replace";
|
|
14
|
+
|
|
15
|
+
export type PublishSourceInput = {
|
|
16
|
+
worktreeRoot: string;
|
|
17
|
+
sourceId: string;
|
|
18
|
+
markdown: string;
|
|
19
|
+
assets?: SourceWriterAsset[];
|
|
20
|
+
metadataJson: string;
|
|
21
|
+
mode: PublishSourceMode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type PreparedSourcePaths = {
|
|
25
|
+
sourceRoot: string;
|
|
26
|
+
realSourceRoot: string;
|
|
27
|
+
rawMarkdownPath: string;
|
|
28
|
+
metadataPath: string;
|
|
29
|
+
createdDirectories: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type PreparedAsset = {
|
|
33
|
+
path: string;
|
|
34
|
+
content: Buffer | Uint8Array | string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type ReplaceRecord = {
|
|
38
|
+
finalPath: string;
|
|
39
|
+
backupPath?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export async function publishSource(input: PublishSourceInput): Promise<void> {
|
|
43
|
+
assertValidSourceId(input.sourceId);
|
|
44
|
+
const createdDirectories: string[] = [];
|
|
45
|
+
const prepared = await prepareSourceForWrite(input.worktreeRoot, input.sourceId, input.mode === "create" ? createdDirectories : undefined);
|
|
46
|
+
|
|
47
|
+
if (input.mode === "replace") {
|
|
48
|
+
const assets = await prepareSourceAssets(prepared, input.assets ?? []);
|
|
49
|
+
await publishSourceReplace(prepared, assets, input);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const assets = await prepareSourceAssets(prepared, input.assets ?? [], createdDirectories);
|
|
55
|
+
await publishSourceCreate(prepared, assets, input);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
await cleanupCreatedDirectories(createdDirectories);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function assertNoExistingSourceForCreate(worktreeRoot: string, sourceId: string): Promise<void> {
|
|
63
|
+
assertValidSourceId(sourceId);
|
|
64
|
+
const realWorktreeRoot = await realpath(worktreeRoot);
|
|
65
|
+
const sourcesRoot = path.join(worktreeRoot, "sources");
|
|
66
|
+
const existingSourcesRoot = await lstatIfExists(sourcesRoot);
|
|
67
|
+
if (existingSourcesRoot) {
|
|
68
|
+
if (existingSourcesRoot.isSymbolicLink()) {
|
|
69
|
+
throw new WikiError("invalid_path", `Sources directory is a symlink: ${sourcesRoot}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!existingSourcesRoot.isDirectory()) {
|
|
73
|
+
throw new WikiError("invalid_path", `Sources directory is not a directory: ${sourcesRoot}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const realSourcesRoot = await realpath(sourcesRoot);
|
|
77
|
+
ensureInsideRealRoot(realWorktreeRoot, realSourcesRoot, sourcesRoot, "Sources directory escapes boundary");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sourceRoot = sourceDirectory(worktreeRoot, sourceId);
|
|
81
|
+
const existingSourceRoot = await lstatIfExists(sourceRoot);
|
|
82
|
+
if (existingSourceRoot?.isSymbolicLink()) {
|
|
83
|
+
throw new WikiError("invalid_path", `Source directory is a symlink: ${sourceRoot}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (existingSourceRoot) {
|
|
87
|
+
throw new WikiError("source_exists", `Source already exists: ${sourceId}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function publishSourceCreate(
|
|
92
|
+
prepared: PreparedSourcePaths,
|
|
93
|
+
assets: PreparedAsset[],
|
|
94
|
+
input: PublishSourceInput
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const published: string[] = [];
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await publishFileCreate(prepared.sourceRoot, prepared.rawMarkdownPath, input.markdown, input.sourceId);
|
|
100
|
+
published.push(prepared.rawMarkdownPath);
|
|
101
|
+
|
|
102
|
+
for (const asset of assets) {
|
|
103
|
+
await publishFileCreate(path.dirname(asset.path), asset.path, asset.content, path.basename(asset.path));
|
|
104
|
+
published.push(asset.path);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await publishFileCreate(prepared.sourceRoot, prepared.metadataPath, input.metadataJson, input.sourceId);
|
|
108
|
+
published.push(prepared.metadataPath);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await Promise.all(published.map(filePath => unlinkIfExists(filePath)));
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function publishSourceReplace(
|
|
116
|
+
prepared: PreparedSourcePaths,
|
|
117
|
+
assets: PreparedAsset[],
|
|
118
|
+
input: PublishSourceInput
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const replaced: ReplaceRecord[] = [];
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await publishFileReplace(prepared.sourceRoot, prepared.rawMarkdownPath, input.markdown, replaced);
|
|
124
|
+
|
|
125
|
+
for (const asset of assets) {
|
|
126
|
+
await publishFileReplace(path.dirname(asset.path), asset.path, asset.content, replaced);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await publishFileReplace(prepared.sourceRoot, prepared.metadataPath, input.metadataJson, replaced);
|
|
130
|
+
await Promise.all(replaced.map(record => (record.backupPath ? unlinkIfExists(record.backupPath) : Promise.resolve())));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await rollbackReplacements(replaced);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function prepareSourceForWrite(
|
|
138
|
+
worktreeRoot: string,
|
|
139
|
+
sourceId: string,
|
|
140
|
+
createdDirectories?: string[]
|
|
141
|
+
): Promise<PreparedSourcePaths> {
|
|
142
|
+
const realWorktreeRoot = await realpath(worktreeRoot);
|
|
143
|
+
const sourcesRoot = path.join(worktreeRoot, "sources");
|
|
144
|
+
await ensureDirectoryForWrite(sourcesRoot, realWorktreeRoot, "Sources directory", createdDirectories);
|
|
145
|
+
const realSourcesRoot = await realpath(sourcesRoot);
|
|
146
|
+
|
|
147
|
+
const sourceRoot = sourceDirectory(worktreeRoot, sourceId);
|
|
148
|
+
await ensureDirectoryForWrite(sourceRoot, realSourcesRoot, "Source directory", createdDirectories);
|
|
149
|
+
const realSourceRoot = await realpath(sourceRoot);
|
|
150
|
+
|
|
151
|
+
const rawMarkdownPath = sourceRawMarkdownPath(worktreeRoot, sourceId);
|
|
152
|
+
const metadataPath = sourceMetadataPath(worktreeRoot, sourceId);
|
|
153
|
+
await rejectExistingSymlink(rawMarkdownPath, "Source raw markdown");
|
|
154
|
+
await rejectExistingSymlink(metadataPath, "Source metadata");
|
|
155
|
+
ensureInsideRealRoot(realSourceRoot, path.resolve(rawMarkdownPath), rawMarkdownPath, "Raw markdown path escapes source directory");
|
|
156
|
+
ensureInsideRealRoot(realSourceRoot, path.resolve(metadataPath), metadataPath, "Metadata path escapes source directory");
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
sourceRoot,
|
|
160
|
+
realSourceRoot,
|
|
161
|
+
rawMarkdownPath,
|
|
162
|
+
metadataPath,
|
|
163
|
+
createdDirectories: createdDirectories ?? []
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function lstatIfExists(filePath: string): Promise<Awaited<ReturnType<typeof lstat>> | undefined> {
|
|
168
|
+
try {
|
|
169
|
+
return await lstat(filePath);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (isEnoent(error)) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function prepareSourceAssets(
|
|
180
|
+
prepared: PreparedSourcePaths,
|
|
181
|
+
assets: SourceWriterAsset[],
|
|
182
|
+
createdDirectories?: string[]
|
|
183
|
+
): Promise<PreparedAsset[]> {
|
|
184
|
+
if (assets.length === 0) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const assetsRoot = path.join(prepared.sourceRoot, "assets");
|
|
189
|
+
const resolved = assets.map(asset => ({
|
|
190
|
+
path: resolveAssetPath(assetsRoot, asset.path),
|
|
191
|
+
content: asset.content
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
await ensureDirectoryForWrite(assetsRoot, prepared.realSourceRoot, "Source assets directory", createdDirectories);
|
|
195
|
+
const realAssetsRoot = await realpath(assetsRoot);
|
|
196
|
+
|
|
197
|
+
for (const asset of resolved) {
|
|
198
|
+
const parent = path.dirname(asset.path);
|
|
199
|
+
await ensureNestedDirectoryForWrite(assetsRoot, path.relative(assetsRoot, parent), realAssetsRoot, createdDirectories);
|
|
200
|
+
await rejectExistingSymlink(asset.path, "Parser asset");
|
|
201
|
+
const realParent = await realpath(parent);
|
|
202
|
+
ensureInsideRealRoot(realAssetsRoot, realParent, parent, "Asset parent path escapes assets directory");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return resolved;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveAssetPath(assetsRoot: string, relativePath: string): string {
|
|
209
|
+
if (
|
|
210
|
+
relativePath.length === 0 ||
|
|
211
|
+
path.isAbsolute(relativePath) ||
|
|
212
|
+
path.win32.isAbsolute(relativePath) ||
|
|
213
|
+
relativePath.split(/[\\/]/).includes("..")
|
|
214
|
+
) {
|
|
215
|
+
throw new WikiError("invalid_path", `Invalid parser asset path: ${relativePath}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return path.join(assetsRoot, relativePath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function publishFileCreate(
|
|
222
|
+
tempDirectory: string,
|
|
223
|
+
finalPath: string,
|
|
224
|
+
content: Buffer | Uint8Array | string,
|
|
225
|
+
label: string
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
const tempPath = await writeTempFile(tempDirectory, path.basename(finalPath), content);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await link(tempPath, finalPath);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (isFileExists(error)) {
|
|
233
|
+
throw new WikiError("source_exists", `Source already exists: ${label}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
throw error;
|
|
237
|
+
} finally {
|
|
238
|
+
await unlinkIfExists(tempPath);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function publishFileReplace(
|
|
243
|
+
tempDirectory: string,
|
|
244
|
+
finalPath: string,
|
|
245
|
+
content: Buffer | Uint8Array | string,
|
|
246
|
+
replaced: ReplaceRecord[]
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const tempPath = await writeTempFile(tempDirectory, path.basename(finalPath), content);
|
|
249
|
+
const backupPath = path.join(tempDirectory, `.${path.basename(finalPath)}.${randomBytes(8).toString("hex")}.backup`);
|
|
250
|
+
let record: ReplaceRecord | undefined;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
try {
|
|
254
|
+
await rename(finalPath, backupPath);
|
|
255
|
+
record = { finalPath, backupPath };
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!isEnoent(error)) {
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
record = { finalPath };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await rename(tempPath, finalPath);
|
|
265
|
+
replaced.push(record);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (record?.backupPath) {
|
|
268
|
+
await rename(record.backupPath, record.finalPath);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throw error;
|
|
272
|
+
} finally {
|
|
273
|
+
await unlinkIfExists(tempPath);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function rollbackReplacements(replaced: ReplaceRecord[]): Promise<void> {
|
|
278
|
+
for (const record of [...replaced].reverse()) {
|
|
279
|
+
await unlinkIfExists(record.finalPath);
|
|
280
|
+
if (record.backupPath) {
|
|
281
|
+
await rename(record.backupPath, record.finalPath);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function writeTempFile(directory: string, finalBaseName: string, content: Buffer | Uint8Array | string): Promise<string> {
|
|
287
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
288
|
+
const tempPath = path.join(directory, `.${finalBaseName}.${randomBytes(8).toString("hex")}.tmp`);
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await writeFile(tempPath, content, { flag: "wx" });
|
|
292
|
+
return tempPath;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (isFileExists(error)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
throw new WikiError("validation_failed", `Could not create unique temp file for: ${finalBaseName}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function ensureNestedDirectoryForWrite(
|
|
306
|
+
root: string,
|
|
307
|
+
relativePath: string,
|
|
308
|
+
realBoundary: string,
|
|
309
|
+
createdDirectories?: string[]
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
const segments = relativePath.split(path.sep).filter(segment => segment.length > 0 && segment !== ".");
|
|
312
|
+
let current = root;
|
|
313
|
+
|
|
314
|
+
for (const segment of segments) {
|
|
315
|
+
current = path.join(current, segment);
|
|
316
|
+
await ensureDirectoryForWrite(current, realBoundary, "Asset parent directory", createdDirectories);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function ensureDirectoryForWrite(
|
|
321
|
+
directoryPath: string,
|
|
322
|
+
realBoundary: string,
|
|
323
|
+
label: string,
|
|
324
|
+
createdDirectories?: string[]
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
let created = false;
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const stat = await lstat(directoryPath);
|
|
330
|
+
|
|
331
|
+
if (stat.isSymbolicLink()) {
|
|
332
|
+
throw new WikiError("invalid_path", `${label} is a symlink: ${directoryPath}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!stat.isDirectory()) {
|
|
336
|
+
throw new WikiError("invalid_path", `${label} is not a directory: ${directoryPath}`);
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (!isEnoent(error)) {
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await mkdir(directoryPath);
|
|
344
|
+
created = true;
|
|
345
|
+
await ensureDirectoryNotSymlink(directoryPath, label);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const realDirectory = await realpath(directoryPath);
|
|
349
|
+
ensureInsideRealRoot(realBoundary, realDirectory, directoryPath, `${label} escapes boundary`);
|
|
350
|
+
|
|
351
|
+
if (created) {
|
|
352
|
+
createdDirectories?.push(directoryPath);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function ensureDirectoryNotSymlink(directoryPath: string, label: string): Promise<void> {
|
|
357
|
+
const stat = await lstat(directoryPath);
|
|
358
|
+
|
|
359
|
+
if (stat.isSymbolicLink()) {
|
|
360
|
+
throw new WikiError("invalid_path", `${label} is a symlink: ${directoryPath}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!stat.isDirectory()) {
|
|
364
|
+
throw new WikiError("invalid_path", `${label} is not a directory: ${directoryPath}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function rejectExistingSymlink(filePath: string, label: string): Promise<void> {
|
|
369
|
+
try {
|
|
370
|
+
const stat = await lstat(filePath);
|
|
371
|
+
if (stat.isSymbolicLink()) {
|
|
372
|
+
throw new WikiError("invalid_path", `${label} is a symlink: ${filePath}`);
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (isEnoent(error)) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function ensureInsideRealRoot(realRoot: string, target: string, originalPath: string, message: string): void {
|
|
384
|
+
if (!target.startsWith(`${realRoot}${path.sep}`) && target !== realRoot) {
|
|
385
|
+
throw new WikiError("invalid_path", `${message}: ${originalPath}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function unlinkIfExists(filePath: string): Promise<void> {
|
|
390
|
+
try {
|
|
391
|
+
await unlink(filePath);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!isEnoent(error)) {
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function cleanupCreatedDirectories(createdDirectories: string[]): Promise<void> {
|
|
400
|
+
for (const directoryPath of [...createdDirectories].reverse()) {
|
|
401
|
+
await rmdir(directoryPath).catch(error => {
|
|
402
|
+
if (!isEnoent(error) && !isDirectoryNotEmpty(error)) {
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isEnoent(error: unknown): boolean {
|
|
410
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isFileExists(error: unknown): boolean {
|
|
414
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isDirectoryNotEmpty(error: unknown): boolean {
|
|
418
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOTEMPTY");
|
|
419
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
|
|
3
|
+
export type ParsedFrontmatter = {
|
|
4
|
+
title?: string;
|
|
5
|
+
summary?: string;
|
|
6
|
+
tags?: string[];
|
|
7
|
+
updated?: string;
|
|
8
|
+
sources?: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ParsedMarkdownPage = {
|
|
12
|
+
frontmatter: ParsedFrontmatter;
|
|
13
|
+
content: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FrontmatterIssue = {
|
|
17
|
+
path: string;
|
|
18
|
+
code: "invalid_frontmatter";
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
23
|
+
|
|
24
|
+
export function parseFrontmatter(filePath: string, source: string): {
|
|
25
|
+
page: ParsedMarkdownPage;
|
|
26
|
+
issues: FrontmatterIssue[];
|
|
27
|
+
} {
|
|
28
|
+
let parsed: matter.GrayMatterFile<string>;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
parsed = matter(source);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return {
|
|
34
|
+
page: {
|
|
35
|
+
frontmatter: {},
|
|
36
|
+
content: contentWithoutFrontmatter(source)
|
|
37
|
+
},
|
|
38
|
+
issues: [
|
|
39
|
+
{
|
|
40
|
+
path: filePath,
|
|
41
|
+
code: "invalid_frontmatter",
|
|
42
|
+
message: error instanceof Error ? error.message : "Invalid frontmatter"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const data = parsed.data as Record<string, unknown>;
|
|
49
|
+
const issues: FrontmatterIssue[] = [];
|
|
50
|
+
|
|
51
|
+
if (Object.keys(data).length === 0) {
|
|
52
|
+
issues.push({
|
|
53
|
+
path: filePath,
|
|
54
|
+
code: "invalid_frontmatter",
|
|
55
|
+
message: "Markdown file is missing required frontmatter"
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
const invalidFields = requiredInvalidFields(data);
|
|
59
|
+
|
|
60
|
+
if (invalidFields.length > 0) {
|
|
61
|
+
issues.push({
|
|
62
|
+
path: filePath,
|
|
63
|
+
code: "invalid_frontmatter",
|
|
64
|
+
message: `Invalid frontmatter fields: ${invalidFields.join(", ")}`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
page: {
|
|
71
|
+
frontmatter: {
|
|
72
|
+
title: typeof data.title === "string" ? data.title : undefined,
|
|
73
|
+
summary: typeof data.summary === "string" ? data.summary : undefined,
|
|
74
|
+
tags: isStringArray(data.tags) ? data.tags : undefined,
|
|
75
|
+
updated: typeof data.updated === "string" ? data.updated : undefined,
|
|
76
|
+
sources: isStringArray(data.sources) ? data.sources : undefined
|
|
77
|
+
},
|
|
78
|
+
content: parsed.content
|
|
79
|
+
},
|
|
80
|
+
issues
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requiredInvalidFields(data: Record<string, unknown>): string[] {
|
|
85
|
+
const invalid: string[] = [];
|
|
86
|
+
|
|
87
|
+
if (typeof data.title !== "string" || data.title.trim().length === 0) {
|
|
88
|
+
invalid.push("title");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof data.summary !== "string" || data.summary.trim().length === 0) {
|
|
92
|
+
invalid.push("summary");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isStringArray(data.tags)) {
|
|
96
|
+
invalid.push("tags");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof data.updated !== "string" || !datePattern.test(data.updated)) {
|
|
100
|
+
invalid.push("updated");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (Object.hasOwn(data, "sources") && !isStringArray(data.sources)) {
|
|
104
|
+
invalid.push("sources");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return invalid;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isStringArray(value: unknown): value is string[] {
|
|
111
|
+
return Array.isArray(value) && value.every(item => typeof item === "string");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function contentWithoutFrontmatter(source: string): string {
|
|
115
|
+
const lines = source.split(/\r?\n/);
|
|
116
|
+
|
|
117
|
+
if (lines[0] !== "---") {
|
|
118
|
+
return source;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const closingFenceIndex = lines.findIndex((line, index) => index > 0 && line === "---");
|
|
122
|
+
|
|
123
|
+
if (closingFenceIndex === -1) {
|
|
124
|
+
return source;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines.slice(closingFenceIndex + 1).join("\n");
|
|
128
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { MarkdownLink } from "../markdown/markdown-scanner.js";
|
|
4
|
+
import { normalizeWikiPath, resolveInsideRoot } from "./path-validator.js";
|
|
5
|
+
|
|
6
|
+
export type LinkValidationIssue = {
|
|
7
|
+
path: string;
|
|
8
|
+
code: "broken_link";
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function validateLinks(
|
|
13
|
+
worktreeRoot: string,
|
|
14
|
+
currentWikiPath: string,
|
|
15
|
+
links: MarkdownLink[]
|
|
16
|
+
): Promise<LinkValidationIssue[]> {
|
|
17
|
+
const issues: LinkValidationIssue[] = [];
|
|
18
|
+
|
|
19
|
+
for (const link of links) {
|
|
20
|
+
try {
|
|
21
|
+
const targetPath = link.kind === "wiki" ? link.target : resolveMarkdownTarget(currentWikiPath, link.target);
|
|
22
|
+
const absoluteTarget = resolveInsideRoot(worktreeRoot, targetPath);
|
|
23
|
+
await access(absoluteTarget);
|
|
24
|
+
} catch {
|
|
25
|
+
issues.push({
|
|
26
|
+
path: currentWikiPath,
|
|
27
|
+
code: "broken_link",
|
|
28
|
+
message: `Broken link ${link.raw} points to an invalid or missing target`
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return issues;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveMarkdownTarget(currentWikiPath: string, target: string): string {
|
|
37
|
+
const [targetWithoutHash] = target.split("#", 1);
|
|
38
|
+
|
|
39
|
+
if (!targetWithoutHash) {
|
|
40
|
+
return currentWikiPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (path.posix.isAbsolute(targetWithoutHash) || targetWithoutHash.startsWith("wiki/")) {
|
|
44
|
+
return normalizeWikiPath(targetWithoutHash);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const currentDir = path.posix.dirname(currentWikiPath);
|
|
48
|
+
const resolvedTarget = path.posix.normalize(path.posix.join(currentDir, targetWithoutHash));
|
|
49
|
+
|
|
50
|
+
if (!resolvedTarget.startsWith("wiki/")) {
|
|
51
|
+
throw new Error(`Link target escapes wiki root: ${target}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return normalizeWikiPath(resolvedTarget);
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { WikiError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
export function normalizeWikiPath(input: string): string {
|
|
5
|
+
const unixInput = input.replaceAll("\\", "/");
|
|
6
|
+
|
|
7
|
+
if (path.posix.isAbsolute(unixInput) || /^[A-Za-z]:\//.test(unixInput)) {
|
|
8
|
+
throw new WikiError("invalid_path", `Path must be relative: ${input}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (hasParentSegment(unixInput)) {
|
|
12
|
+
throw new WikiError("invalid_path", `Path escapes wiki root: ${input}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const relativeInput = path.posix.normalize(unixInput);
|
|
16
|
+
|
|
17
|
+
if (relativeInput.startsWith("../") || relativeInput.includes("/../") || relativeInput === "..") {
|
|
18
|
+
throw new WikiError("invalid_path", `Path escapes wiki root: ${input}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const withWiki = toWikiPath(input, relativeInput);
|
|
22
|
+
const parsed = path.posix.normalize(withWiki);
|
|
23
|
+
|
|
24
|
+
if (parsed.startsWith("../") || parsed.includes("/../") || parsed === "..") {
|
|
25
|
+
throw new WikiError("invalid_path", `Path escapes wiki root: ${input}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!parsed.startsWith("wiki/")) {
|
|
29
|
+
throw new WikiError("invalid_path", `Path must be inside wiki/: ${input}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!parsed.endsWith(".md")) {
|
|
33
|
+
throw new WikiError("invalid_path", `Path must point to a Markdown file: ${input}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasParentSegment(input: string): boolean {
|
|
40
|
+
return input.split("/").some(segment => segment === "..");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toWikiPath(input: string, relativeInput: string): string {
|
|
44
|
+
if (relativeInput.startsWith("wiki/")) {
|
|
45
|
+
return relativeInput;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!relativeInput.includes("/")) {
|
|
49
|
+
return `wiki/${relativeInput}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new WikiError("invalid_path", `Path must be inside wiki/: ${input}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveInsideRoot(root: string, wikiPath: string): string {
|
|
56
|
+
const normalized = normalizeWikiPath(wikiPath);
|
|
57
|
+
const absolute = path.resolve(root, normalized);
|
|
58
|
+
const allowedRoot = path.resolve(root, "wiki");
|
|
59
|
+
|
|
60
|
+
if (!absolute.startsWith(`${allowedRoot}${path.sep}`) && absolute !== allowedRoot) {
|
|
61
|
+
throw new WikiError("invalid_path", `Path escapes wiki root: ${wikiPath}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return absolute;
|
|
65
|
+
}
|