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,191 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertValidSourceId } from "../sources/source-id.js";
|
|
4
|
+
import { sourceMetadataPath, sourceRawMarkdownPath } from "../sources/source-paths.js";
|
|
5
|
+
import { parseSourceMetadata } from "../sources/source-validator.js";
|
|
6
|
+
|
|
7
|
+
export type SourceReferenceIssue = {
|
|
8
|
+
path: string;
|
|
9
|
+
code: "broken_link";
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SourceDirectoryValidation = {
|
|
14
|
+
source_id: string;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
message?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function validateSourceReferences(
|
|
20
|
+
worktreeRoot: string,
|
|
21
|
+
wikiPath: string,
|
|
22
|
+
sourceIds: string[] | undefined
|
|
23
|
+
): Promise<SourceReferenceIssue[]> {
|
|
24
|
+
if (!sourceIds || sourceIds.length === 0) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const issues: SourceReferenceIssue[] = [];
|
|
29
|
+
|
|
30
|
+
for (const sourceId of sourceIds) {
|
|
31
|
+
const validation = await validateSourceDirectory(worktreeRoot, sourceId);
|
|
32
|
+
|
|
33
|
+
if (!validation.ok) {
|
|
34
|
+
issues.push({
|
|
35
|
+
path: wikiPath,
|
|
36
|
+
code: "broken_link",
|
|
37
|
+
message: validation.message ?? `Source reference does not resolve: ${sourceId}`
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return issues;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function validateSourceDirectory(worktreeRoot: string, sourceId: string): Promise<SourceDirectoryValidation> {
|
|
46
|
+
try {
|
|
47
|
+
assertValidSourceId(sourceId);
|
|
48
|
+
|
|
49
|
+
const sourcesRoot = path.join(worktreeRoot, "sources");
|
|
50
|
+
const sourceRoot = path.join(sourcesRoot, sourceId);
|
|
51
|
+
const metadataPath = sourceMetadataPath(worktreeRoot, sourceId);
|
|
52
|
+
const rawMarkdownPath = sourceRawMarkdownPath(worktreeRoot, sourceId);
|
|
53
|
+
|
|
54
|
+
await assertDirectoryNotSymlink(sourcesRoot);
|
|
55
|
+
await assertDirectoryNotSymlink(sourceRoot);
|
|
56
|
+
await assertFileNotSymlink(metadataPath);
|
|
57
|
+
await assertFileNotSymlink(rawMarkdownPath);
|
|
58
|
+
|
|
59
|
+
const realSourceRoot = await realpath(sourceRoot);
|
|
60
|
+
const realMetadataPath = await realpath(metadataPath);
|
|
61
|
+
const realRawMarkdownPath = await realpath(rawMarkdownPath);
|
|
62
|
+
|
|
63
|
+
if (!isInsideRealRoot(realSourceRoot, realMetadataPath) || !isInsideRealRoot(realSourceRoot, realRawMarkdownPath)) {
|
|
64
|
+
return {
|
|
65
|
+
source_id: sourceId,
|
|
66
|
+
ok: false,
|
|
67
|
+
message: `Source files escape source directory: ${sourceId}`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const metadata = parseSourceMetadata(JSON.parse(await readFile(metadataPath, "utf8")));
|
|
72
|
+
|
|
73
|
+
if (metadata.documentId !== sourceId || metadata.rawMarkdownPath !== `sources/${sourceId}/raw.md`) {
|
|
74
|
+
return {
|
|
75
|
+
source_id: sourceId,
|
|
76
|
+
ok: false,
|
|
77
|
+
message: `Source metadata does not match source id: ${sourceId}`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
source_id: sourceId,
|
|
83
|
+
ok: true
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
source_id: sourceId,
|
|
88
|
+
ok: false,
|
|
89
|
+
message: error instanceof Error ? error.message : `Source reference does not resolve: ${sourceId}`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function validateSourceAssetPaths(
|
|
95
|
+
worktreeRoot: string,
|
|
96
|
+
sourceId: string,
|
|
97
|
+
assetPaths: string[]
|
|
98
|
+
): Promise<SourceDirectoryValidation> {
|
|
99
|
+
try {
|
|
100
|
+
assertValidSourceId(sourceId);
|
|
101
|
+
|
|
102
|
+
const sourceRoot = path.join(worktreeRoot, "sources", sourceId);
|
|
103
|
+
const assetsRoot = path.join(sourceRoot, "assets");
|
|
104
|
+
await assertDirectoryNotSymlink(sourceRoot);
|
|
105
|
+
|
|
106
|
+
if (!(await pathExists(assetsRoot))) {
|
|
107
|
+
return {
|
|
108
|
+
source_id: sourceId,
|
|
109
|
+
ok: true
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await assertDirectoryNotSymlink(assetsRoot);
|
|
114
|
+
const realAssetsRoot = await realpath(assetsRoot);
|
|
115
|
+
|
|
116
|
+
for (const assetPath of assetPaths) {
|
|
117
|
+
if (!assetPath.startsWith(`sources/${sourceId}/assets/`)) {
|
|
118
|
+
throw new Error(`Invalid source asset path: ${assetPath}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const assetFilePath = path.join(worktreeRoot, ...assetPath.split("/"));
|
|
122
|
+
let stat;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
stat = await lstat(assetFilePath);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (isEnoent(error)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
135
|
+
throw new Error(`Unsafe source asset path: ${assetPath}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isInsideRealRoot(realAssetsRoot, await realpath(assetFilePath))) {
|
|
139
|
+
throw new Error(`Source asset escapes assets directory: ${assetPath}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
source_id: sourceId,
|
|
145
|
+
ok: true
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return {
|
|
149
|
+
source_id: sourceId,
|
|
150
|
+
ok: false,
|
|
151
|
+
message: error instanceof Error ? error.message : `Source asset validation failed: ${sourceId}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function assertDirectoryNotSymlink(directoryPath: string): Promise<void> {
|
|
157
|
+
const stat = await lstat(directoryPath);
|
|
158
|
+
|
|
159
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
160
|
+
throw new Error(`Unsafe source directory: ${directoryPath}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function assertFileNotSymlink(filePath: string): Promise<void> {
|
|
165
|
+
const stat = await lstat(filePath);
|
|
166
|
+
|
|
167
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
168
|
+
throw new Error(`Unsafe source file path: ${filePath}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isInsideRealRoot(realRoot: string, target: string): boolean {
|
|
173
|
+
return target.startsWith(`${realRoot}${path.sep}`) && target !== realRoot;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
177
|
+
try {
|
|
178
|
+
await lstat(filePath);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (isEnoent(error)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isEnoent(error: unknown): boolean {
|
|
190
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
191
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { firstH1, findMarkdownLinks } from "../markdown/markdown-scanner.js";
|
|
3
|
+
import { WikiError } from "../errors.js";
|
|
4
|
+
import { parseFrontmatter, type FrontmatterIssue } from "./frontmatter-validator.js";
|
|
5
|
+
import { validateLinks, type LinkValidationIssue } from "./link-validator.js";
|
|
6
|
+
import { normalizeWikiPath, resolveInsideRoot } from "./path-validator.js";
|
|
7
|
+
import { validateSourceReferences, type SourceReferenceIssue } from "./source-reference-validator.js";
|
|
8
|
+
|
|
9
|
+
export type ValidationErrorCode = "invalid_path" | "invalid_frontmatter" | "missing_h1" | "broken_link";
|
|
10
|
+
export type ValidationWarningCode = "title_h1_mismatch";
|
|
11
|
+
|
|
12
|
+
export type ValidationError = {
|
|
13
|
+
path: string;
|
|
14
|
+
code: ValidationErrorCode;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ValidationWarning = {
|
|
19
|
+
path: string;
|
|
20
|
+
code: ValidationWarningCode;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ValidationResult = {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
errors: ValidationError[];
|
|
27
|
+
warnings: ValidationWarning[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class ValidationService {
|
|
31
|
+
constructor(private readonly worktreeRoot: string) {}
|
|
32
|
+
|
|
33
|
+
async validatePaths(paths: string[]): Promise<ValidationResult> {
|
|
34
|
+
const errors: ValidationError[] = [];
|
|
35
|
+
const warnings: ValidationWarning[] = [];
|
|
36
|
+
|
|
37
|
+
for (const inputPath of paths) {
|
|
38
|
+
let wikiPath: string;
|
|
39
|
+
let absolutePath: string;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
wikiPath = normalizeWikiPath(inputPath);
|
|
43
|
+
absolutePath = resolveInsideRoot(this.worktreeRoot, wikiPath);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
errors.push(pathError(inputPath, error));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let source: string;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
source = await readFile(absolutePath, "utf8");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
errors.push({
|
|
55
|
+
path: wikiPath,
|
|
56
|
+
code: "invalid_path",
|
|
57
|
+
message: error instanceof Error ? error.message : `Unable to read Markdown file: ${wikiPath}`
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { page, issues: frontmatterIssues } = parseFrontmatter(wikiPath, source);
|
|
63
|
+
errors.push(...frontmatterIssues.map(toValidationError));
|
|
64
|
+
|
|
65
|
+
const h1 = firstH1(page.content);
|
|
66
|
+
|
|
67
|
+
if (!h1) {
|
|
68
|
+
errors.push({
|
|
69
|
+
path: wikiPath,
|
|
70
|
+
code: "missing_h1",
|
|
71
|
+
message: `Markdown file is missing an H1 heading: ${wikiPath}`
|
|
72
|
+
});
|
|
73
|
+
} else if (page.frontmatter.title && page.frontmatter.title !== h1) {
|
|
74
|
+
warnings.push({
|
|
75
|
+
path: wikiPath,
|
|
76
|
+
code: "title_h1_mismatch",
|
|
77
|
+
message: `Frontmatter title "${page.frontmatter.title}" does not match H1 "${h1}"`
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const links = findMarkdownLinks(page.content);
|
|
82
|
+
errors.push(...(await validateLinks(this.worktreeRoot, wikiPath, links)).map(toValidationError));
|
|
83
|
+
errors.push(
|
|
84
|
+
...(await validateSourceReferences(this.worktreeRoot, wikiPath, page.frontmatter.sources)).map(toValidationError)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ok: errors.length === 0,
|
|
90
|
+
errors,
|
|
91
|
+
warnings
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pathError(inputPath: string, error: unknown): ValidationError {
|
|
97
|
+
return {
|
|
98
|
+
path: inputPath,
|
|
99
|
+
code: "invalid_path",
|
|
100
|
+
message: error instanceof WikiError ? error.message : `Invalid wiki path: ${inputPath}`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toValidationError(issue: FrontmatterIssue | LinkValidationIssue | SourceReferenceIssue): ValidationError {
|
|
105
|
+
return issue;
|
|
106
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { WikiError } from "../errors.js";
|
|
2
|
+
|
|
3
|
+
export type VfsCommandName = "pwd" | "ls" | "find" | "cat" | "grep" | "head" | "tail" | "wc" | "tree";
|
|
4
|
+
|
|
5
|
+
export type VfsCommand = {
|
|
6
|
+
name: VfsCommandName;
|
|
7
|
+
args: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const allowedCommands = new Set<VfsCommandName>(["pwd", "ls", "find", "cat", "grep", "head", "tail", "wc", "tree"]);
|
|
11
|
+
|
|
12
|
+
export function parseVfsCommand(command: string): VfsCommand {
|
|
13
|
+
const tokens = tokenize(command);
|
|
14
|
+
const [name, ...args] = tokens;
|
|
15
|
+
|
|
16
|
+
if (!name || !isVfsCommandName(name)) {
|
|
17
|
+
throw new WikiError("vfs_command_denied", `Unsupported VFS command: ${name ?? ""}`.trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { name, args };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isVfsCommandName(value: string): value is VfsCommandName {
|
|
24
|
+
return allowedCommands.has(value as VfsCommandName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tokenize(input: string): string[] {
|
|
28
|
+
const tokens: string[] = [];
|
|
29
|
+
let current = "";
|
|
30
|
+
let quote: "'" | "\"" | undefined;
|
|
31
|
+
|
|
32
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
33
|
+
const char = input[index];
|
|
34
|
+
|
|
35
|
+
if (quote) {
|
|
36
|
+
if (char === quote) {
|
|
37
|
+
quote = undefined;
|
|
38
|
+
} else {
|
|
39
|
+
current += char;
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (char === "'" || char === "\"") {
|
|
45
|
+
quote = char;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (/\s/.test(char)) {
|
|
50
|
+
if (current.length > 0) {
|
|
51
|
+
tokens.push(current);
|
|
52
|
+
current = "";
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
current += char;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (quote) {
|
|
61
|
+
throw new WikiError("vfs_command_denied", "Unterminated quoted string");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (current.length > 0) {
|
|
65
|
+
tokens.push(current);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return tokens;
|
|
69
|
+
}
|