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,13 @@
|
|
|
1
|
+
import type { WikiError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export type Result<T> =
|
|
4
|
+
| { ok: true; value: T }
|
|
5
|
+
| { ok: false; error: WikiError };
|
|
6
|
+
|
|
7
|
+
export function ok<T>(value: T): Result<T> {
|
|
8
|
+
return { ok: true, value };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function err<T = never>(error: WikiError): Result<T> {
|
|
12
|
+
return { ok: false, error };
|
|
13
|
+
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveWorkspacePaths, type WorkspacePaths } from "../config/workspace-resolver.js";
|
|
5
|
+
import { WikiError } from "../errors.js";
|
|
6
|
+
import { GitService } from "../git/git-service.js";
|
|
7
|
+
import { err, ok, type Result } from "../result.js";
|
|
8
|
+
import { SessionService, type SessionStartInput, type SessionStartResult } from "../session/session-service.js";
|
|
9
|
+
import { SessionStore } from "../session/session-store.js";
|
|
10
|
+
import type { SessionMetadata } from "../session/session-types.js";
|
|
11
|
+
import { assertValidSourceId } from "../sources/source-id.js";
|
|
12
|
+
import { normalizeWikiPath, resolveInsideRoot } from "../validation/path-validator.js";
|
|
13
|
+
import { validateSourceAssetPaths, validateSourceDirectory } from "../validation/source-reference-validator.js";
|
|
14
|
+
import { ValidationService, type ValidationResult } from "../validation/validation-service.js";
|
|
15
|
+
|
|
16
|
+
export type SessionPatchInput = {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
path: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
patch?: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SessionPatchResult = {
|
|
25
|
+
path: string;
|
|
26
|
+
changed: boolean;
|
|
27
|
+
sha_before?: string;
|
|
28
|
+
sha_after: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SessionValidateInput = {
|
|
32
|
+
sessionId: string;
|
|
33
|
+
paths?: string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SessionDiffInput = {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
paths?: string[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type SessionDiffResult = {
|
|
42
|
+
session_id: string;
|
|
43
|
+
base_ref: string;
|
|
44
|
+
diff: string;
|
|
45
|
+
committed_diff: string;
|
|
46
|
+
working_diff: string;
|
|
47
|
+
changed_files: string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SessionCommitInput = {
|
|
51
|
+
sessionId: string;
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SessionCommitResult = {
|
|
56
|
+
commit: string;
|
|
57
|
+
branch: string;
|
|
58
|
+
changed_files: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export class SessionWorkflowService {
|
|
62
|
+
private readonly paths: WorkspacePaths;
|
|
63
|
+
private readonly store: SessionStore;
|
|
64
|
+
private readonly sessions: SessionService;
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
workspaceRoot: string,
|
|
68
|
+
idGenerator?: () => string,
|
|
69
|
+
private readonly git = new GitService()
|
|
70
|
+
) {
|
|
71
|
+
this.paths = resolveWorkspacePaths(workspaceRoot);
|
|
72
|
+
this.store = new SessionStore(this.paths);
|
|
73
|
+
this.sessions = new SessionService(workspaceRoot, idGenerator, git);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async start(input: SessionStartInput = {}): Promise<Result<SessionStartResult>> {
|
|
77
|
+
return this.sessions.start(input);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async patch(input: SessionPatchInput): Promise<Result<SessionPatchResult>> {
|
|
81
|
+
try {
|
|
82
|
+
if (input.patch !== undefined) {
|
|
83
|
+
return err(new WikiError("invalid_path", "Full content replacement is required; unified patch input is not supported"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (input.content === undefined) {
|
|
87
|
+
return err(new WikiError("invalid_path", "Full content replacement is required"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isSourcePatchAttempt(input.path)) {
|
|
91
|
+
return err(new WikiError("source_write_denied", "Source files are parser-owned and cannot be patched directly"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const metadata = await this.openSession(input.sessionId);
|
|
95
|
+
const wikiPath = normalizeWikiPath(input.path);
|
|
96
|
+
const worktreeRoot = this.worktreeRoot(metadata);
|
|
97
|
+
const filePath = resolveInsideRoot(worktreeRoot, wikiPath);
|
|
98
|
+
await ensureSafeWriteTarget(worktreeRoot, wikiPath, filePath);
|
|
99
|
+
const before = await readOptional(filePath);
|
|
100
|
+
const shaBefore = before === undefined ? undefined : contentSha(before);
|
|
101
|
+
const shaAfter = contentSha(input.content);
|
|
102
|
+
|
|
103
|
+
await writeFile(filePath, input.content, "utf8");
|
|
104
|
+
await this.touch(input.sessionId);
|
|
105
|
+
|
|
106
|
+
return ok({
|
|
107
|
+
path: wikiPath,
|
|
108
|
+
changed: before !== input.content,
|
|
109
|
+
...(shaBefore ? { sha_before: shaBefore } : {}),
|
|
110
|
+
sha_after: shaAfter
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return err(toWorkflowError(error, "invalid_path"));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async validate(input: SessionValidateInput): Promise<Result<ValidationResult>> {
|
|
118
|
+
try {
|
|
119
|
+
const metadata = await this.store.read(input.sessionId);
|
|
120
|
+
const worktreeRoot = this.worktreeRoot(metadata);
|
|
121
|
+
const paths = validationPaths(input.paths?.map(normalizeWikiPath) ?? (await this.changedFiles(metadata)));
|
|
122
|
+
const validation = new ValidationService(worktreeRoot);
|
|
123
|
+
|
|
124
|
+
return ok(await validation.validatePaths(paths));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return err(toWorkflowError(error, "session_not_found"));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async diff(input: SessionDiffInput): Promise<Result<SessionDiffResult>> {
|
|
131
|
+
try {
|
|
132
|
+
const metadata = await this.store.read(input.sessionId);
|
|
133
|
+
const worktreeRoot = this.worktreeRoot(metadata);
|
|
134
|
+
const diffPaths = diffPathspec(input.paths);
|
|
135
|
+
const addIntentPaths = await existingPathspecs(worktreeRoot, diffPaths);
|
|
136
|
+
|
|
137
|
+
if (addIntentPaths.length > 0) {
|
|
138
|
+
await this.git.run(["add", "-N", ...addIntentPaths], { cwd: worktreeRoot });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const [committed, working, changedFiles] = await Promise.all([
|
|
142
|
+
this.git.run(["diff", metadata.baseCommit, "HEAD", "--", ...diffPaths], { cwd: worktreeRoot }),
|
|
143
|
+
this.git.run(["diff", "HEAD", "--", ...diffPaths], { cwd: worktreeRoot }),
|
|
144
|
+
this.changedFiles(metadata, input.paths)
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const sections = [committed.stdout, working.stdout].filter(section => section.length > 0);
|
|
148
|
+
|
|
149
|
+
return ok({
|
|
150
|
+
session_id: metadata.sessionId,
|
|
151
|
+
base_ref: metadata.baseRef,
|
|
152
|
+
diff: sections.join("\n"),
|
|
153
|
+
committed_diff: committed.stdout,
|
|
154
|
+
working_diff: working.stdout,
|
|
155
|
+
changed_files: changedFiles
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return err(toWorkflowError(error, "git_error"));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async commit(input: SessionCommitInput): Promise<Result<SessionCommitResult>> {
|
|
163
|
+
try {
|
|
164
|
+
const metadata = await this.openSession(input.sessionId);
|
|
165
|
+
const worktreeRoot = this.worktreeRoot(metadata);
|
|
166
|
+
const pendingFiles = await this.pendingFiles(metadata, ["."]);
|
|
167
|
+
const unsupportedFiles = pendingFiles.filter(file => !isAllowedSessionCommitPath(file));
|
|
168
|
+
|
|
169
|
+
if (unsupportedFiles.length > 0) {
|
|
170
|
+
return err(
|
|
171
|
+
new WikiError("validation_failed", "Session contains unsupported pending files", { files: unsupportedFiles })
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const changedFiles = pendingFiles.filter(isAllowedSessionCommitPath);
|
|
176
|
+
|
|
177
|
+
if (changedFiles.length === 0) {
|
|
178
|
+
return err(new WikiError("session_dirty", "No session changes to commit"));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const sourceValidationErrors = (
|
|
182
|
+
await Promise.all(affectedSourceIds(changedFiles).map(sourceId => validateSourceDirectory(worktreeRoot, sourceId)))
|
|
183
|
+
).filter(result => !result.ok);
|
|
184
|
+
const assetValidationErrors = (
|
|
185
|
+
await Promise.all(
|
|
186
|
+
affectedAssetSourceIds(changedFiles).map(sourceId =>
|
|
187
|
+
validateSourceAssetPaths(worktreeRoot, sourceId, changedFiles.filter(file => isSourceAssetPath(file, sourceId)))
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
).filter(result => !result.ok);
|
|
191
|
+
const allSourceValidationErrors = [...sourceValidationErrors, ...assetValidationErrors];
|
|
192
|
+
|
|
193
|
+
if (allSourceValidationErrors.length > 0) {
|
|
194
|
+
return err(
|
|
195
|
+
new WikiError("validation_failed", "Session contains invalid source files", {
|
|
196
|
+
sources: allSourceValidationErrors
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const validation = await this.validate({ sessionId: input.sessionId });
|
|
202
|
+
|
|
203
|
+
if (!validation.ok) {
|
|
204
|
+
return validation;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!validation.value.ok) {
|
|
208
|
+
return err(new WikiError("validation_failed", "Session validation failed", { validation: validation.value }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const commit = await this.git.commit(worktreeRoot, input.message, changedFiles);
|
|
212
|
+
await this.store.update(input.sessionId, current => ({
|
|
213
|
+
...current,
|
|
214
|
+
lastCommit: commit,
|
|
215
|
+
updatedAt: new Date().toISOString()
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
return ok({
|
|
219
|
+
commit,
|
|
220
|
+
branch: metadata.branch,
|
|
221
|
+
changed_files: changedFiles
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return err(toWorkflowError(error, "git_error"));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async openSession(sessionId: string): Promise<SessionMetadata> {
|
|
229
|
+
const metadata = await this.store.read(sessionId);
|
|
230
|
+
|
|
231
|
+
if (metadata.status !== "open") {
|
|
232
|
+
throw new WikiError("session_dirty", `Session is not open: ${sessionId}`, { status: metadata.status });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return metadata;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private worktreeRoot(metadata: SessionMetadata): string {
|
|
239
|
+
const absolute = path.resolve(this.paths.workspaceRoot, metadata.worktree);
|
|
240
|
+
const sessionsRoot = path.resolve(this.paths.sessionsWorktreePath);
|
|
241
|
+
|
|
242
|
+
if (!absolute.startsWith(`${sessionsRoot}${path.sep}`) && absolute !== sessionsRoot) {
|
|
243
|
+
throw new WikiError("session_metadata_invalid", `Session worktree escapes sessions root: ${metadata.sessionId}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return absolute;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async changedFiles(metadata: SessionMetadata, paths?: string[]): Promise<string[]> {
|
|
250
|
+
const worktreeRoot = this.worktreeRoot(metadata);
|
|
251
|
+
const diffPaths = diffPathspec(paths);
|
|
252
|
+
|
|
253
|
+
const results = await Promise.all([
|
|
254
|
+
this.git.run(["diff", "--name-only", "-z", metadata.baseCommit, "HEAD", "--", ...diffPaths], { cwd: worktreeRoot }),
|
|
255
|
+
this.pendingFileOutput(worktreeRoot, diffPaths)
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
const changedPaths = uniquePaths(results.flatMap(result => parseNulPaths(result.stdout)));
|
|
259
|
+
return paths === undefined ? changedPaths.filter(isAllowedSessionCommitPath) : uniqueWikiPaths(changedPaths);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async pendingFiles(metadata: SessionMetadata, diffPaths: string[]): Promise<string[]> {
|
|
263
|
+
const worktreeRoot = this.worktreeRoot(metadata);
|
|
264
|
+
return uniquePaths(parseNulPaths((await this.pendingFileOutput(worktreeRoot, diffPaths)).stdout));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async pendingFileOutput(worktreeRoot: string, diffPaths: string[]): Promise<{ stdout: string }> {
|
|
268
|
+
const results = await Promise.all([
|
|
269
|
+
this.git.run(["diff", "--name-only", "-z", "HEAD", "--", ...diffPaths], { cwd: worktreeRoot }),
|
|
270
|
+
this.git.run(["diff", "--name-only", "-z", "--cached", "HEAD", "--", ...diffPaths], { cwd: worktreeRoot }),
|
|
271
|
+
this.git.run(["ls-files", "-z", "--others", "--exclude-standard", "--", ...diffPaths], { cwd: worktreeRoot })
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
stdout: results.map(result => result.stdout).join("")
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async touch(sessionId: string): Promise<void> {
|
|
280
|
+
await this.store.update(sessionId, metadata => ({
|
|
281
|
+
...metadata,
|
|
282
|
+
updatedAt: new Date().toISOString()
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function readOptional(filePath: string): Promise<string | undefined> {
|
|
288
|
+
try {
|
|
289
|
+
return await readFile(filePath, "utf8");
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (isEnoent(error)) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function ensureSafeWriteTarget(worktreeRoot: string, wikiPath: string, filePath: string): Promise<void> {
|
|
300
|
+
const wikiRootPath = path.join(worktreeRoot, "wiki");
|
|
301
|
+
await ensureDirectoryNotSymlink(worktreeRoot);
|
|
302
|
+
await ensureDirectoryNotSymlink(wikiRootPath);
|
|
303
|
+
const realWikiRoot = await realpath(wikiRootPath);
|
|
304
|
+
const relativePath = wikiPath.slice("wiki/".length);
|
|
305
|
+
const parentSegments = path.posix.dirname(relativePath).split("/").filter(segment => segment.length > 0 && segment !== ".");
|
|
306
|
+
let currentPath = wikiRootPath;
|
|
307
|
+
|
|
308
|
+
for (const segment of parentSegments) {
|
|
309
|
+
currentPath = path.join(currentPath, segment);
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const stat = await lstat(currentPath);
|
|
313
|
+
|
|
314
|
+
if (stat.isSymbolicLink()) {
|
|
315
|
+
throw new WikiError("invalid_path", `Path is a symlink: ${currentPath}`);
|
|
316
|
+
}
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (isEnoent(error)) {
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const stat = await lstat(filePath);
|
|
328
|
+
|
|
329
|
+
if (stat.isSymbolicLink()) {
|
|
330
|
+
throw new WikiError("invalid_path", `Path is a symlink: ${filePath}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const realTarget = await realpath(filePath);
|
|
334
|
+
ensureInsideRealWikiRoot(realWikiRoot, realTarget, filePath);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!isEnoent(error)) {
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const parentPath = path.dirname(filePath);
|
|
342
|
+
await mkdir(parentPath, { recursive: true });
|
|
343
|
+
const realParent = await realpath(parentPath);
|
|
344
|
+
ensureInsideRealWikiRoot(realWikiRoot, realParent, parentPath);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function ensureDirectoryNotSymlink(directoryPath: string): Promise<void> {
|
|
348
|
+
let stat;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
stat = await lstat(directoryPath);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (isEnoent(error)) {
|
|
354
|
+
throw new WikiError("invalid_path", `Directory does not exist: ${directoryPath}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (stat.isSymbolicLink()) {
|
|
361
|
+
throw new WikiError("invalid_path", `Path is a symlink: ${directoryPath}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!stat.isDirectory()) {
|
|
365
|
+
throw new WikiError("invalid_path", `Path is not a directory: ${directoryPath}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function ensureInsideRealWikiRoot(realWikiRoot: string, target: string, originalPath: string): void {
|
|
370
|
+
if (!target.startsWith(`${realWikiRoot}${path.sep}`) && target !== realWikiRoot) {
|
|
371
|
+
throw new WikiError("invalid_path", `Path escapes wiki root: ${originalPath}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isEnoent(error: unknown): boolean {
|
|
376
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function contentSha(content: string): string {
|
|
380
|
+
return createHash("sha1").update(content).digest("hex");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function uniqueWikiPaths(paths: string[]): string[] {
|
|
384
|
+
return uniquePaths(paths).filter(isWikiMarkdownPath);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function uniquePaths(paths: string[]): string[] {
|
|
388
|
+
return [...new Set(paths)].sort();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function diffPathspec(paths?: string[]): string[] {
|
|
392
|
+
if (paths !== undefined) {
|
|
393
|
+
return [...new Set(paths.map(normalizeWikiPath))].sort();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return ["wiki", "sources"];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function parseNulPaths(output: string): string[] {
|
|
400
|
+
return output.length === 0 ? [] : output.split("\0").filter(path => path.length > 0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isWikiMarkdownPath(filePath: string): boolean {
|
|
404
|
+
return filePath.startsWith("wiki/") && filePath.endsWith(".md");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isSourcePatchAttempt(filePath: string): boolean {
|
|
408
|
+
return filePath.replaceAll("\\", "/").startsWith("sources/");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isSourcePath(filePath: string): boolean {
|
|
412
|
+
const segments = filePath.split("/");
|
|
413
|
+
|
|
414
|
+
if (segments.length < 3 || segments[0] !== "sources") {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
assertValidSourceId(segments[1]);
|
|
420
|
+
} catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (segments.length === 3 && (segments[2] === "raw.md" || segments[2] === "metadata.json")) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
segments.length >= 4 &&
|
|
430
|
+
segments[2] === "assets" &&
|
|
431
|
+
segments.slice(3).every(segment => segment.length > 0 && segment !== "." && segment !== "..")
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function isAllowedSessionCommitPath(filePath: string): boolean {
|
|
436
|
+
return isWikiMarkdownPath(filePath) || isSourcePath(filePath);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function validationPaths(paths: string[]): string[] {
|
|
440
|
+
return uniqueWikiPaths(paths);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function affectedSourceIds(paths: string[]): string[] {
|
|
444
|
+
return uniquePaths(paths.filter(isSourcePath).map(filePath => filePath.split("/")[1]));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function affectedAssetSourceIds(paths: string[]): string[] {
|
|
448
|
+
return uniquePaths(
|
|
449
|
+
paths
|
|
450
|
+
.filter(filePath => isSourcePath(filePath) && filePath.split("/")[2] === "assets")
|
|
451
|
+
.map(filePath => filePath.split("/")[1])
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isSourceAssetPath(filePath: string, sourceId: string): boolean {
|
|
456
|
+
return filePath.startsWith(`sources/${sourceId}/assets/`) && isSourcePath(filePath);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
460
|
+
try {
|
|
461
|
+
await lstat(filePath);
|
|
462
|
+
return true;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (isEnoent(error)) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function existingPathspecs(worktreeRoot: string, pathspecs: string[]): Promise<string[]> {
|
|
473
|
+
const existing = await Promise.all(
|
|
474
|
+
pathspecs.map(async pathspec => ({
|
|
475
|
+
pathspec,
|
|
476
|
+
exists: await pathExists(path.join(worktreeRoot, pathspec))
|
|
477
|
+
}))
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
return existing.filter(item => item.exists).map(item => item.pathspec);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function toWorkflowError(error: unknown, fallbackCode: "git_error" | "invalid_path" | "session_not_found"): WikiError {
|
|
484
|
+
if (error instanceof WikiError) {
|
|
485
|
+
return error;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (error instanceof Error) {
|
|
489
|
+
return new WikiError(fallbackCode, error.message);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return new WikiError(fallbackCode, String(error));
|
|
493
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { resolveWorkspacePaths, type WorkspacePaths } from "../config/workspace-resolver.js";
|
|
6
|
+
import { WikiError } from "../errors.js";
|
|
7
|
+
import { GitService } from "../git/git-service.js";
|
|
8
|
+
import { err, ok, type Result } from "../result.js";
|
|
9
|
+
import { normalizeWikiPath, resolveInsideRoot } from "../validation/path-validator.js";
|
|
10
|
+
|
|
11
|
+
export type WikiReadInput = {
|
|
12
|
+
path: string;
|
|
13
|
+
ref?: "stable" | string;
|
|
14
|
+
includeFrontmatter?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type WikiReadResult = {
|
|
18
|
+
path: string;
|
|
19
|
+
ref: "stable";
|
|
20
|
+
frontmatter?: Record<string, unknown>;
|
|
21
|
+
content: string;
|
|
22
|
+
sha: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ChannelStatusInput = {
|
|
26
|
+
channel?: "stable" | string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ChannelStatusResult = {
|
|
30
|
+
channel: "stable";
|
|
31
|
+
branch: "wiki/stable";
|
|
32
|
+
commit: string;
|
|
33
|
+
worktree: string;
|
|
34
|
+
dirty: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class WikiService {
|
|
38
|
+
private readonly paths: WorkspacePaths;
|
|
39
|
+
private readonly git: GitService;
|
|
40
|
+
|
|
41
|
+
constructor(workspaceRoot: string, git = new GitService()) {
|
|
42
|
+
this.paths = resolveWorkspacePaths(workspaceRoot);
|
|
43
|
+
this.git = git;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async read(input: WikiReadInput): Promise<Result<WikiReadResult>> {
|
|
47
|
+
if ((input.ref ?? "stable") !== "stable") {
|
|
48
|
+
return err(new WikiError("unsupported_channel", `Unsupported wiki ref: ${input.ref}`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const wikiPath = normalizeWikiPath(input.path);
|
|
53
|
+
const filePath = resolveInsideRoot(this.paths.stableWorktreePath, wikiPath);
|
|
54
|
+
await ensureRealPathInsideWikiRoot(filePath, this.paths.stableWorktreePath);
|
|
55
|
+
const raw = await readFile(filePath, "utf8");
|
|
56
|
+
const parsed = parseMarkdown(raw);
|
|
57
|
+
|
|
58
|
+
return ok({
|
|
59
|
+
path: wikiPath,
|
|
60
|
+
ref: "stable",
|
|
61
|
+
...(input.includeFrontmatter ? { frontmatter: parsed.data } : {}),
|
|
62
|
+
content: parsed.content,
|
|
63
|
+
sha: contentSha(raw)
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof WikiError) {
|
|
67
|
+
return err(error);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return err(new WikiError("invalid_path", error instanceof Error ? error.message : String(error)));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async channelStatus(input: ChannelStatusInput): Promise<Result<ChannelStatusResult>> {
|
|
75
|
+
if ((input.channel ?? "stable") !== "stable") {
|
|
76
|
+
return err(new WikiError("unsupported_channel", `Unsupported wiki channel: ${input.channel}`));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const commit = (await this.git.run(["rev-parse", "HEAD"], { cwd: this.paths.stableWorktreePath })).stdout.trim();
|
|
81
|
+
const status = (await this.git.run(["status", "--short"], { cwd: this.paths.stableWorktreePath })).stdout.trim();
|
|
82
|
+
|
|
83
|
+
return ok({
|
|
84
|
+
channel: "stable",
|
|
85
|
+
branch: "wiki/stable",
|
|
86
|
+
commit,
|
|
87
|
+
worktree: this.paths.stableWorktreePath,
|
|
88
|
+
dirty: status.length > 0
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof WikiError) {
|
|
92
|
+
return err(error);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return err(new WikiError("git_error", error instanceof Error ? error.message : String(error)));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function contentSha(content: string): string {
|
|
101
|
+
return createHash("sha1").update(content).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function ensureRealPathInsideWikiRoot(filePath: string, stableWorktreePath: string): Promise<void> {
|
|
105
|
+
const wikiRoot = await realpath(path.join(stableWorktreePath, "wiki"));
|
|
106
|
+
const target = await realpath(filePath);
|
|
107
|
+
|
|
108
|
+
if (!target.startsWith(`${wikiRoot}${path.sep}`) && target !== wikiRoot) {
|
|
109
|
+
throw new WikiError("invalid_path", `Path escapes wiki root: ${filePath}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseMarkdown(raw: string): ReturnType<typeof matter> {
|
|
114
|
+
try {
|
|
115
|
+
return matter(raw);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new WikiError("invalid_frontmatter", error instanceof Error ? error.message : String(error));
|
|
118
|
+
}
|
|
119
|
+
}
|