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.
Files changed (68) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +255 -0
  3. package/docs/mcp-usage.md +631 -0
  4. package/docs/v0-acceptance.md +105 -0
  5. package/docs/v0-delivery-checklist.md +57 -0
  6. package/docs/v1-acceptance.md +39 -0
  7. package/docs/v2-acceptance.md +165 -0
  8. package/package.json +69 -0
  9. package/packages/core/src/config/config-loader.ts +109 -0
  10. package/packages/core/src/config/defaults.ts +74 -0
  11. package/packages/core/src/config/workspace-resolver.ts +40 -0
  12. package/packages/core/src/documents/command-document-parser.ts +206 -0
  13. package/packages/core/src/documents/document-id.ts +26 -0
  14. package/packages/core/src/documents/document-parser-registry.ts +126 -0
  15. package/packages/core/src/documents/document-service.ts +656 -0
  16. package/packages/core/src/documents/document-store.ts +132 -0
  17. package/packages/core/src/documents/document-types.ts +33 -0
  18. package/packages/core/src/documents/pdf-text-parser.ts +35 -0
  19. package/packages/core/src/documents/text-markdown-parser.ts +50 -0
  20. package/packages/core/src/errors.ts +46 -0
  21. package/packages/core/src/git/git-service.ts +68 -0
  22. package/packages/core/src/index.ts +38 -0
  23. package/packages/core/src/markdown/markdown-scanner.ts +90 -0
  24. package/packages/core/src/permissions/permission-service.ts +50 -0
  25. package/packages/core/src/publish/publish-service.ts +142 -0
  26. package/packages/core/src/result.ts +13 -0
  27. package/packages/core/src/services/session-workflow-service.ts +493 -0
  28. package/packages/core/src/services/wiki-service.ts +119 -0
  29. package/packages/core/src/services/workspace-service.ts +223 -0
  30. package/packages/core/src/session/session-id.ts +14 -0
  31. package/packages/core/src/session/session-service.ts +77 -0
  32. package/packages/core/src/session/session-store.ts +91 -0
  33. package/packages/core/src/session/session-types.ts +17 -0
  34. package/packages/core/src/sources/source-id.ts +19 -0
  35. package/packages/core/src/sources/source-paths.ts +15 -0
  36. package/packages/core/src/sources/source-service.ts +416 -0
  37. package/packages/core/src/sources/source-types.ts +77 -0
  38. package/packages/core/src/sources/source-validator.ts +132 -0
  39. package/packages/core/src/sources/source-writer.ts +419 -0
  40. package/packages/core/src/validation/frontmatter-validator.ts +128 -0
  41. package/packages/core/src/validation/link-validator.ts +55 -0
  42. package/packages/core/src/validation/path-validator.ts +65 -0
  43. package/packages/core/src/validation/source-reference-validator.ts +191 -0
  44. package/packages/core/src/validation/validation-service.ts +106 -0
  45. package/packages/core/src/vfs/vfs-command-parser.ts +69 -0
  46. package/packages/core/src/vfs/vfs-service.ts +498 -0
  47. package/packages/core/src/web/html-to-markdown.ts +144 -0
  48. package/packages/core/src/web/static-web-fetcher.ts +537 -0
  49. package/packages/core/src/web/web-id.ts +26 -0
  50. package/packages/core/src/web/web-ingestion-service.ts +335 -0
  51. package/packages/core/src/web/web-paths.ts +6 -0
  52. package/packages/core/src/web/web-types.ts +33 -0
  53. package/packages/server/src/cli.ts +56 -0
  54. package/packages/server/src/context.ts +7 -0
  55. package/packages/server/src/index.ts +2 -0
  56. package/packages/server/src/mcp-server.ts +111 -0
  57. package/packages/server/src/schemas/documents.ts +17 -0
  58. package/packages/server/src/schemas/read.ts +16 -0
  59. package/packages/server/src/schemas/session.ts +31 -0
  60. package/packages/server/src/schemas/sources.ts +12 -0
  61. package/packages/server/src/schemas/web.ts +23 -0
  62. package/packages/server/src/tools/document-tools.ts +46 -0
  63. package/packages/server/src/tools/publish-tools.ts +33 -0
  64. package/packages/server/src/tools/read-tools.ts +52 -0
  65. package/packages/server/src/tools/response.ts +24 -0
  66. package/packages/server/src/tools/session-tools.ts +100 -0
  67. package/packages/server/src/tools/source-tools.ts +32 -0
  68. 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
+ }