@chatroomcp/chatroom 0.2.1 → 0.2.2
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/README.md +1 -1
- package/dist/app/application.d.ts +0 -1
- package/dist/app/application.js +2 -0
- package/dist/app/event-bus.d.ts +0 -2
- package/dist/app/event-bus.js +0 -1
- package/dist/app/external-access-registry.d.ts +0 -1
- package/dist/app/external-access-registry.js +0 -1
- package/dist/app/lifecycle.d.ts +0 -1
- package/dist/auth/auth-service.js +0 -4
- package/dist/auth/ingress-policy.d.ts +0 -1
- package/dist/auth/ingress-policy.js +0 -1
- package/dist/cli/run-cli.js +0 -1
- package/dist/config/types.d.ts +0 -1
- package/dist/core/auth/repository.d.ts +0 -23
- package/dist/core/auth/repository.js +0 -6
- package/dist/core/errors/chatroom-error.d.ts +0 -15
- package/dist/core/errors/chatroom-error.js +0 -15
- package/dist/core/operations/bounded-value.d.ts +0 -1
- package/dist/core/operations/bounded-value.js +26 -10
- package/dist/core/operations/redactor.js +0 -1
- package/dist/core/operations/repository.d.ts +0 -1
- package/dist/core/operations/types.d.ts +0 -4
- package/dist/core/operations/types.js +0 -2
- package/dist/infrastructure/database/app-database.js +4 -97
- package/dist/infrastructure/database/migrations.d.ts +2 -0
- package/dist/infrastructure/database/migrations.js +44 -0
- package/dist/infrastructure/database/operation-repository.js +3 -9
- package/dist/infrastructure/database/schema.d.ts +2 -0
- package/dist/infrastructure/database/schema.js +85 -0
- package/dist/infrastructure/http/http-server.js +0 -1
- package/dist/mcp/server/create-mcp-server.js +3 -3
- package/dist/mcp/server/plugin-mcp-registrar.d.ts +0 -2
- package/dist/mcp/server/plugin-mcp-registrar.js +0 -4
- package/dist/mcp/server/tool-support.d.ts +0 -61
- package/dist/mcp/server/tool-support.js +0 -54
- package/dist/native/linux/chatroom-computer-helper +0 -0
- package/dist/native/macos/ChatRoomComputerHelper.app/Contents/Info.plist +1 -1
- package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
- package/dist/native/windows/chatroom-computer-helper.exe +0 -0
- package/dist/operations/operation-log.d.ts +0 -3
- package/dist/operations/operation-log.js +0 -15
- package/dist/plugins/cloud/api-client.js +1 -0
- package/dist/plugins/cloud/controller.d.ts +2 -0
- package/dist/plugins/cloud/controller.js +28 -5
- package/dist/plugins/cloud/tunnel-client.js +0 -4
- package/dist/plugins/git/git-service.d.ts +33 -0
- package/dist/plugins/git/git-service.js +414 -0
- package/dist/plugins/git/plugin.d.ts +4 -0
- package/dist/plugins/git/plugin.js +12 -0
- package/dist/plugins/git/types.d.ts +32 -0
- package/dist/plugins/process/backend.d.ts +0 -1
- package/dist/plugins/process/head-tail-buffer.d.ts +0 -1
- package/dist/plugins/process/infrastructure/pipe-process-backend.js +0 -1
- package/dist/plugins/process/infrastructure/pty-process-backend.js +1 -4
- package/dist/plugins/process/process-supervisor.js +10 -23
- package/dist/plugins/process/types.d.ts +0 -5
- package/dist/plugins/types.d.ts +0 -1
- package/dist/plugins/web/api-types.d.ts +3 -7
- package/dist/plugins/web/http/api-router.d.ts +0 -1
- package/dist/plugins/web/http/api-router.js +2 -4
- package/dist/plugins/web/http/git-api-router.d.ts +3 -0
- package/dist/plugins/web/http/git-api-router.js +98 -0
- package/dist/plugins/web/http/workspace-api-router.js +15 -37
- package/dist/plugins/web/plugin.js +4 -6
- package/dist/plugins/web/runtime.d.ts +5 -27
- package/dist/plugins/web/runtime.js +3 -42
- package/dist/plugins/workspace/mcp.js +17 -135
- package/dist/plugins/workspace/metadata.d.ts +4 -0
- package/dist/plugins/workspace/metadata.js +105 -0
- package/dist/plugins/workspace/plugin.d.ts +2 -8
- package/dist/plugins/workspace/plugin.js +8 -20
- package/dist/plugins/workspace/types.d.ts +18 -0
- package/dist/plugins/workspace/{infrastructure/workspace-fs.d.ts → workspace-fs.d.ts} +2 -15
- package/dist/plugins/workspace/workspace-fs.js +111 -0
- package/dist/plugins/workspace/workspace-service.d.ts +7 -42
- package/dist/plugins/workspace/workspace-service.js +61 -315
- package/dist/presentation/http/http-utils.d.ts +0 -1
- package/dist/presentation/http/oauth-router.d.ts +0 -1
- package/dist/presentation/http/oauth-router.js +0 -1
- package/dist/web/assets/{index-B4vO7wVS.css → index-DFKEvyzR.css} +1 -1
- package/dist/web/assets/index-vzPPspuL.js +26 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -3
- package/dist/plugins/workspace/domain/filesystem.d.ts +0 -25
- package/dist/plugins/workspace/domain/git.d.ts +0 -7
- package/dist/plugins/workspace/domain/repository.d.ts +0 -16
- package/dist/plugins/workspace/domain/repository.js +0 -1
- package/dist/plugins/workspace/domain/review.d.ts +0 -28
- package/dist/plugins/workspace/domain/review.js +0 -1
- package/dist/plugins/workspace/domain/workspace.d.ts +0 -23
- package/dist/plugins/workspace/domain/workspace.js +0 -1
- package/dist/plugins/workspace/git/git-command-runner.d.ts +0 -6
- package/dist/plugins/workspace/git/git-command-runner.js +0 -15
- package/dist/plugins/workspace/git/git-service.d.ts +0 -14
- package/dist/plugins/workspace/git/git-service.js +0 -107
- package/dist/plugins/workspace/git/git-snapshot-service.d.ts +0 -33
- package/dist/plugins/workspace/git/git-snapshot-service.js +0 -352
- package/dist/plugins/workspace/infrastructure/workspace-fs.js +0 -340
- package/dist/plugins/workspace/infrastructure/workspace-repository.d.ts +0 -12
- package/dist/plugins/workspace/infrastructure/workspace-repository.js +0 -51
- package/dist/plugins/workspace/workspace-helpers.d.ts +0 -9
- package/dist/plugins/workspace/workspace-helpers.js +0 -67
- package/dist/plugins/workspace/worktree-review-service.d.ts +0 -16
- package/dist/plugins/workspace/worktree-review-service.js +0 -51
- package/dist/web/assets/index-BU7LCgKB.js +0 -26
- /package/dist/plugins/{workspace/domain/filesystem.js → git/types.js} +0 -0
- /package/dist/plugins/workspace/{domain/git.js → types.js} +0 -0
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, open, rm, stat } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { ChatRoomError } from "../../../core/errors/chatroom-error.js";
|
|
5
|
-
export class GitSnapshotService {
|
|
6
|
-
runner;
|
|
7
|
-
git;
|
|
8
|
-
constructor(runner, git) {
|
|
9
|
-
this.runner = runner;
|
|
10
|
-
this.git = git;
|
|
11
|
-
}
|
|
12
|
-
async fileDiff(cwd, filePath, maxPreviewBytes = 2 * 1024 * 1024) {
|
|
13
|
-
return this.withIndex(cwd, async ({ env, tempDir }) => {
|
|
14
|
-
await this.stageAll(cwd, env);
|
|
15
|
-
const state = await this.stateFromIndex(cwd, env);
|
|
16
|
-
if (!state.byPath.has(filePath))
|
|
17
|
-
throw new ChatRoomError("NOT_FOUND", "Worktree file change not found");
|
|
18
|
-
const patchPath = path.join(tempDir, "file.patch");
|
|
19
|
-
await this.writeHeadPatch(cwd, env, patchPath, [filePath]);
|
|
20
|
-
return {
|
|
21
|
-
path: filePath,
|
|
22
|
-
...(await readPatchPreview(patchPath, maxPreviewBytes)),
|
|
23
|
-
};
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
async apply(worktreeRoot, sourceRoot, requestedPaths) {
|
|
27
|
-
const [worktreeHead, sourceHead, worktree, source] = await Promise.all([
|
|
28
|
-
this.git.head(worktreeRoot),
|
|
29
|
-
this.git.head(sourceRoot),
|
|
30
|
-
this.snapshot(worktreeRoot),
|
|
31
|
-
this.snapshot(sourceRoot),
|
|
32
|
-
]);
|
|
33
|
-
if (!worktreeHead || !sourceHead || worktreeHead !== sourceHead)
|
|
34
|
-
throw new ChatRoomError("CONFLICT", "Source checkout HEAD no longer matches the worktree HEAD");
|
|
35
|
-
if (!worktree.state.files.length)
|
|
36
|
-
throw new ChatRoomError("CONFLICT", "Worktree has no changes to apply");
|
|
37
|
-
const baseTree = await this.baseTree(worktreeRoot, worktreeHead);
|
|
38
|
-
const fullAnalysis = await this.analyze(worktreeRoot, baseTree, source.tree, worktree.tree, worktree.state.files.map((file) => file.path));
|
|
39
|
-
const reviewed = reviewFiles(worktree.state, source.state, fullAnalysis);
|
|
40
|
-
const pending = reviewed.filter((file) => !file.applied);
|
|
41
|
-
const selected = resolveRequestedPaths(pending, requestedPaths);
|
|
42
|
-
const selectedConflicts = selected
|
|
43
|
-
.filter((file) => file.conflict)
|
|
44
|
-
.map((file) => file.path);
|
|
45
|
-
if (requestedPaths !== undefined && selectedConflicts.length)
|
|
46
|
-
throw new ChatRoomError("CONFLICT", "Selected worktree files have merge conflicts", {
|
|
47
|
-
paths: selectedConflicts,
|
|
48
|
-
});
|
|
49
|
-
const paths = selected
|
|
50
|
-
.filter((file) => !file.conflict)
|
|
51
|
-
.map((file) => file.path);
|
|
52
|
-
if (!paths.length)
|
|
53
|
-
throw new ChatRoomError("CONFLICT", "No selected worktree files can be merged automatically", {
|
|
54
|
-
paths: selectedConflicts,
|
|
55
|
-
});
|
|
56
|
-
const selectedTree = await this.treeForPaths(worktreeRoot, paths);
|
|
57
|
-
const merge = await this.analyze(worktreeRoot, baseTree, source.tree, selectedTree, paths);
|
|
58
|
-
if (merge.conflicts.size)
|
|
59
|
-
throw new ChatRoomError("CONFLICT", "Selected worktree files have merge conflicts", {
|
|
60
|
-
paths: [...merge.conflicts],
|
|
61
|
-
});
|
|
62
|
-
return this.withIndex(worktreeRoot, async ({ tempDir }) => {
|
|
63
|
-
const patchPath = path.join(tempDir, "merge.patch");
|
|
64
|
-
await this.writeTreePatch(sourceRoot, patchPath, source.tree, merge.mergedTree, paths);
|
|
65
|
-
const bytes = (await stat(patchPath)).size;
|
|
66
|
-
if (bytes === 0)
|
|
67
|
-
throw new ChatRoomError("CONFLICT", "Selected worktree files are already integrated");
|
|
68
|
-
const [currentWorktreeHead, currentSourceHead, currentSource, currentSelectedTree,] = await Promise.all([
|
|
69
|
-
this.git.head(worktreeRoot),
|
|
70
|
-
this.git.head(sourceRoot),
|
|
71
|
-
this.snapshot(sourceRoot),
|
|
72
|
-
this.treeForPaths(worktreeRoot, paths),
|
|
73
|
-
]);
|
|
74
|
-
if (currentWorktreeHead !== worktreeHead ||
|
|
75
|
-
currentSourceHead !== sourceHead ||
|
|
76
|
-
currentSource.tree !== source.tree ||
|
|
77
|
-
currentSelectedTree !== selectedTree)
|
|
78
|
-
throw new ChatRoomError("CONFLICT", "Workspace changed while preparing worktree merge");
|
|
79
|
-
await this.runner.run(sourceRoot, [
|
|
80
|
-
"apply",
|
|
81
|
-
"--check",
|
|
82
|
-
"--binary",
|
|
83
|
-
patchPath,
|
|
84
|
-
]);
|
|
85
|
-
await this.runner.run(sourceRoot, ["apply", "--binary", patchPath]);
|
|
86
|
-
return { bytes, paths, conflicts: selectedConflicts };
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
async applyState(worktreeRoot, sourceRoot) {
|
|
90
|
-
const [worktreeHead, sourceHead, worktree, source] = await Promise.all([
|
|
91
|
-
this.git.head(worktreeRoot),
|
|
92
|
-
this.git.head(sourceRoot),
|
|
93
|
-
this.snapshot(worktreeRoot),
|
|
94
|
-
this.snapshot(sourceRoot),
|
|
95
|
-
]);
|
|
96
|
-
if (!worktreeHead || !sourceHead || worktreeHead !== sourceHead) {
|
|
97
|
-
return {
|
|
98
|
-
files: worktree.state.files.map(({ target, ...file }) => ({
|
|
99
|
-
...file,
|
|
100
|
-
applied: source.state.byPath.get(file.path)?.target === target,
|
|
101
|
-
conflict: false,
|
|
102
|
-
})),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const baseTree = await this.baseTree(worktreeRoot, worktreeHead);
|
|
106
|
-
const analysis = await this.analyze(worktreeRoot, baseTree, source.tree, worktree.tree, worktree.state.files.map((file) => file.path));
|
|
107
|
-
return { files: reviewFiles(worktree.state, source.state, analysis) };
|
|
108
|
-
}
|
|
109
|
-
async analyze(cwd, baseTree, oursTree, theirsTree, paths) {
|
|
110
|
-
const merge = await this.mergeTrees(cwd, baseTree, oursTree, theirsTree);
|
|
111
|
-
const delta = await this.runner.run(cwd, [
|
|
112
|
-
"diff",
|
|
113
|
-
"--name-only",
|
|
114
|
-
"-z",
|
|
115
|
-
"--no-renames",
|
|
116
|
-
oursTree,
|
|
117
|
-
merge.mergedTree,
|
|
118
|
-
"--",
|
|
119
|
-
...paths,
|
|
120
|
-
]);
|
|
121
|
-
return {
|
|
122
|
-
...merge,
|
|
123
|
-
deltaPaths: new Set(delta.stdout.split("\0").filter(Boolean)),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
async mergeTrees(cwd, baseTree, oursTree, theirsTree) {
|
|
127
|
-
const args = [
|
|
128
|
-
"merge-tree",
|
|
129
|
-
"--write-tree",
|
|
130
|
-
`--merge-base=${baseTree}`,
|
|
131
|
-
"--messages",
|
|
132
|
-
"--name-only",
|
|
133
|
-
"-z",
|
|
134
|
-
oursTree,
|
|
135
|
-
theirsTree,
|
|
136
|
-
];
|
|
137
|
-
let stdout;
|
|
138
|
-
try {
|
|
139
|
-
stdout = (await this.runner.run(cwd, args)).stdout;
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
const details = error instanceof ChatRoomError
|
|
143
|
-
? error.details
|
|
144
|
-
: undefined;
|
|
145
|
-
if (error instanceof ChatRoomError &&
|
|
146
|
-
error.code === "PROCESS_FAILED" &&
|
|
147
|
-
details?.exitCode === 1)
|
|
148
|
-
stdout = String(details.stdout ?? "");
|
|
149
|
-
else
|
|
150
|
-
throw error;
|
|
151
|
-
}
|
|
152
|
-
const tokens = stdout.split("\0");
|
|
153
|
-
const mergedTree = tokens[0]?.trim() ?? "";
|
|
154
|
-
if (!/^[0-9a-f]{40,64}$/.test(mergedTree))
|
|
155
|
-
throw new ChatRoomError("INTERNAL", "Git merge-tree did not return a merged tree");
|
|
156
|
-
const conflicts = new Set();
|
|
157
|
-
for (let index = 1; index < tokens.length && tokens[index] !== ""; index++) {
|
|
158
|
-
if (tokens[index])
|
|
159
|
-
conflicts.add(tokens[index]);
|
|
160
|
-
}
|
|
161
|
-
return { mergedTree, conflicts };
|
|
162
|
-
}
|
|
163
|
-
async snapshot(cwd) {
|
|
164
|
-
return this.withIndex(cwd, async ({ env }) => {
|
|
165
|
-
await this.stageAll(cwd, env);
|
|
166
|
-
const [state, tree] = await Promise.all([
|
|
167
|
-
this.stateFromIndex(cwd, env),
|
|
168
|
-
this.writeTree(cwd, env),
|
|
169
|
-
]);
|
|
170
|
-
return { state, tree };
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
async treeForPaths(cwd, paths) {
|
|
174
|
-
return this.withIndex(cwd, async ({ env }) => {
|
|
175
|
-
await this.runner.run(cwd, ["add", "-A", "--", ...paths], env);
|
|
176
|
-
return this.writeTree(cwd, env);
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
async baseTree(cwd, head) {
|
|
180
|
-
return (await this.runner.run(cwd, ["rev-parse", `${head}^{tree}`])).stdout.trim();
|
|
181
|
-
}
|
|
182
|
-
async writeTree(cwd, env) {
|
|
183
|
-
return (await this.runner.run(cwd, ["write-tree"], env)).stdout.trim();
|
|
184
|
-
}
|
|
185
|
-
async stageAll(cwd, env) {
|
|
186
|
-
await this.runner.run(cwd, ["add", "-A", "--", "."], env);
|
|
187
|
-
}
|
|
188
|
-
async stateFromIndex(cwd, env) {
|
|
189
|
-
const [raw, numstat] = await Promise.all([
|
|
190
|
-
this.runner.run(cwd, [
|
|
191
|
-
"diff",
|
|
192
|
-
"--cached",
|
|
193
|
-
"--raw",
|
|
194
|
-
"-z",
|
|
195
|
-
"--no-renames",
|
|
196
|
-
"--abbrev=40",
|
|
197
|
-
"HEAD",
|
|
198
|
-
], env),
|
|
199
|
-
this.runner.run(cwd, ["diff", "--cached", "--numstat", "-z", "--no-renames", "HEAD"], env),
|
|
200
|
-
]);
|
|
201
|
-
const stats = parseNumstat(numstat.stdout);
|
|
202
|
-
const tokens = raw.stdout.split("\0");
|
|
203
|
-
const files = [];
|
|
204
|
-
for (let index = 0; index + 1 < tokens.length; index += 2) {
|
|
205
|
-
const header = tokens[index];
|
|
206
|
-
const filePath = tokens[index + 1];
|
|
207
|
-
if (!header || !filePath)
|
|
208
|
-
continue;
|
|
209
|
-
const match = /^:(\d{6}) (\d{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([AMDT])$/.exec(header);
|
|
210
|
-
if (!match)
|
|
211
|
-
throw new ChatRoomError("INTERNAL", `Unexpected Git raw diff entry: ${header}`);
|
|
212
|
-
const statEntry = stats.get(filePath) ?? {
|
|
213
|
-
additions: null,
|
|
214
|
-
deletions: null,
|
|
215
|
-
binary: true,
|
|
216
|
-
};
|
|
217
|
-
files.push({
|
|
218
|
-
path: filePath,
|
|
219
|
-
status: rawStatus(match[5]),
|
|
220
|
-
additions: statEntry.additions,
|
|
221
|
-
deletions: statEntry.deletions,
|
|
222
|
-
binary: statEntry.binary,
|
|
223
|
-
target: `${match[2]}:${match[4]}`,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
return { files, byPath: new Map(files.map((file) => [file.path, file])) };
|
|
227
|
-
}
|
|
228
|
-
async writeHeadPatch(cwd, env, patchPath, paths) {
|
|
229
|
-
await this.runner.run(cwd, [
|
|
230
|
-
"diff",
|
|
231
|
-
"--cached",
|
|
232
|
-
"--binary",
|
|
233
|
-
"--no-ext-diff",
|
|
234
|
-
"--no-renames",
|
|
235
|
-
`--output=${patchPath}`,
|
|
236
|
-
"HEAD",
|
|
237
|
-
"--",
|
|
238
|
-
...paths,
|
|
239
|
-
], env);
|
|
240
|
-
}
|
|
241
|
-
async writeTreePatch(cwd, patchPath, fromTree, toTree, paths) {
|
|
242
|
-
await this.runner.run(cwd, [
|
|
243
|
-
"diff",
|
|
244
|
-
"--binary",
|
|
245
|
-
"--no-ext-diff",
|
|
246
|
-
"--no-renames",
|
|
247
|
-
`--output=${patchPath}`,
|
|
248
|
-
fromTree,
|
|
249
|
-
toTree,
|
|
250
|
-
"--",
|
|
251
|
-
...paths,
|
|
252
|
-
]);
|
|
253
|
-
}
|
|
254
|
-
async withIndex(cwd, useIndex) {
|
|
255
|
-
const tempDir = await mkdtemp(path.join(os.tmpdir(), "chatroom-worktree-"));
|
|
256
|
-
const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
|
|
257
|
-
try {
|
|
258
|
-
await this.runner.run(cwd, ["read-tree", "HEAD"], env);
|
|
259
|
-
return await useIndex({ env, tempDir });
|
|
260
|
-
}
|
|
261
|
-
finally {
|
|
262
|
-
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
function reviewFiles(worktree, source, analysis) {
|
|
267
|
-
const mappedConflicts = mapConflictPaths(worktree.files, analysis.conflicts);
|
|
268
|
-
return worktree.files.map(({ target, ...file }) => {
|
|
269
|
-
const conflict = mappedConflicts.has(file.path);
|
|
270
|
-
const exact = source.byPath.get(file.path)?.target === target;
|
|
271
|
-
const integrated = !conflict && !analysis.deltaPaths.has(file.path);
|
|
272
|
-
return { ...file, applied: exact || integrated, conflict };
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
function mapConflictPaths(files, conflicts) {
|
|
276
|
-
const mapped = new Set();
|
|
277
|
-
for (const file of files) {
|
|
278
|
-
for (const conflict of conflicts) {
|
|
279
|
-
if (conflict === file.path ||
|
|
280
|
-
conflict.startsWith(`${file.path}/`) ||
|
|
281
|
-
file.path.startsWith(`${conflict}/`)) {
|
|
282
|
-
mapped.add(file.path);
|
|
283
|
-
break;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (conflicts.size && !mapped.size)
|
|
288
|
-
for (const file of files)
|
|
289
|
-
mapped.add(file.path);
|
|
290
|
-
return mapped;
|
|
291
|
-
}
|
|
292
|
-
function resolveRequestedPaths(pending, requestedPaths) {
|
|
293
|
-
if (requestedPaths === undefined)
|
|
294
|
-
return pending;
|
|
295
|
-
const requested = [...new Set(requestedPaths)];
|
|
296
|
-
if (!requested.length)
|
|
297
|
-
throw new ChatRoomError("INVALID_INPUT", "At least one file path is required");
|
|
298
|
-
const byPath = new Map(pending.map((file) => [file.path, file]));
|
|
299
|
-
return requested.map((filePath) => {
|
|
300
|
-
const file = byPath.get(filePath);
|
|
301
|
-
if (!file)
|
|
302
|
-
throw new ChatRoomError("CONFLICT", `Worktree file is already applied or unavailable: ${filePath}`);
|
|
303
|
-
return file;
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
function rawStatus(value) {
|
|
307
|
-
if (value === "A")
|
|
308
|
-
return "added";
|
|
309
|
-
if (value === "D")
|
|
310
|
-
return "deleted";
|
|
311
|
-
if (value === "T")
|
|
312
|
-
return "type-changed";
|
|
313
|
-
return "modified";
|
|
314
|
-
}
|
|
315
|
-
function parseNumstat(value) {
|
|
316
|
-
const result = new Map();
|
|
317
|
-
for (const entry of value.split("\0")) {
|
|
318
|
-
if (!entry)
|
|
319
|
-
continue;
|
|
320
|
-
const first = entry.indexOf("\t");
|
|
321
|
-
const second = first < 0 ? -1 : entry.indexOf("\t", first + 1);
|
|
322
|
-
if (first < 0 || second < 0)
|
|
323
|
-
continue;
|
|
324
|
-
const additionsText = entry.slice(0, first);
|
|
325
|
-
const deletionsText = entry.slice(first + 1, second);
|
|
326
|
-
const filePath = entry.slice(second + 1);
|
|
327
|
-
const binary = additionsText === "-" || deletionsText === "-";
|
|
328
|
-
result.set(filePath, {
|
|
329
|
-
additions: binary ? null : Number(additionsText),
|
|
330
|
-
deletions: binary ? null : Number(deletionsText),
|
|
331
|
-
binary,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
return result;
|
|
335
|
-
}
|
|
336
|
-
async function readPatchPreview(patchPath, maxPreviewBytes) {
|
|
337
|
-
const size = (await stat(patchPath)).size;
|
|
338
|
-
const previewBytes = Math.min(size, maxPreviewBytes);
|
|
339
|
-
const handle = await open(patchPath, "r");
|
|
340
|
-
try {
|
|
341
|
-
const buffer = Buffer.alloc(previewBytes);
|
|
342
|
-
const { bytesRead } = await handle.read(buffer, 0, previewBytes, 0);
|
|
343
|
-
return {
|
|
344
|
-
diff: buffer.subarray(0, bytesRead).toString("utf8"),
|
|
345
|
-
bytes: size,
|
|
346
|
-
truncated: size > previewBytes,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
finally {
|
|
350
|
-
await handle.close();
|
|
351
|
-
}
|
|
352
|
-
}
|
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workspace-scoped filesystem implementation and the primary path-containment boundary for ChatRoom file tools.
|
|
3
|
-
* It rejects absolute/traversal paths and prevents reads or writes that escape through symbolic links.
|
|
4
|
-
*/
|
|
5
|
-
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { ChatRoomError } from "../../../core/errors/chatroom-error.js";
|
|
9
|
-
export class WorkspaceFs {
|
|
10
|
-
root;
|
|
11
|
-
realRoot;
|
|
12
|
-
constructor(root, realRoot) {
|
|
13
|
-
this.root = root;
|
|
14
|
-
this.realRoot = realRoot;
|
|
15
|
-
}
|
|
16
|
-
static async create(root) {
|
|
17
|
-
const resolved = path.resolve(root);
|
|
18
|
-
const canonical = await realpath(resolved).catch(() => {
|
|
19
|
-
throw new ChatRoomError("NOT_FOUND", `Workspace root does not exist: ${root}`);
|
|
20
|
-
});
|
|
21
|
-
const info = await stat(canonical);
|
|
22
|
-
if (!info.isDirectory())
|
|
23
|
-
throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${root}`);
|
|
24
|
-
return new WorkspaceFs(resolved, canonical);
|
|
25
|
-
}
|
|
26
|
-
async read(relativePath, options = {}) {
|
|
27
|
-
const result = await this.readBytes(relativePath, options);
|
|
28
|
-
return {
|
|
29
|
-
content: result.data.toString("utf8"),
|
|
30
|
-
bytes: result.bytes,
|
|
31
|
-
truncated: result.truncated,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
async readBytes(relativePath, options = {}) {
|
|
35
|
-
const target = await this.resolveReadable(relativePath);
|
|
36
|
-
const info = await stat(target);
|
|
37
|
-
if (!info.isFile())
|
|
38
|
-
throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
|
|
39
|
-
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
40
|
-
const handle = await open(target, "r");
|
|
41
|
-
try {
|
|
42
|
-
const size = Math.min(info.size, maxBytes);
|
|
43
|
-
const buffer = Buffer.alloc(size);
|
|
44
|
-
const result = await handle.read(buffer, 0, size, 0);
|
|
45
|
-
return {
|
|
46
|
-
data: buffer.subarray(0, result.bytesRead),
|
|
47
|
-
bytes: info.size,
|
|
48
|
-
truncated: info.size > maxBytes,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
finally {
|
|
52
|
-
await handle.close();
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
async write(relativePath, content) {
|
|
56
|
-
const target = await this.resolveWritable(relativePath, true);
|
|
57
|
-
let mode = 0o644;
|
|
58
|
-
try {
|
|
59
|
-
mode = (await lstat(target)).mode & 0o777;
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
if (error.code !== "ENOENT")
|
|
63
|
-
throw error;
|
|
64
|
-
}
|
|
65
|
-
// Write beside the destination and rename atomically so readers never observe partially written content.
|
|
66
|
-
const temp = `${target}.chatroom-${randomUUID()}.tmp`;
|
|
67
|
-
await writeFile(temp, content, { flag: "wx", mode });
|
|
68
|
-
try {
|
|
69
|
-
await rename(temp, target);
|
|
70
|
-
}
|
|
71
|
-
finally {
|
|
72
|
-
await rm(temp, { force: true }).catch(() => undefined);
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
path: this.normalizeRelative(relativePath),
|
|
76
|
-
bytes: Buffer.byteLength(content),
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
async list(relativePath = ".", options = {}) {
|
|
80
|
-
const start = await this.resolveReadable(relativePath);
|
|
81
|
-
const maxEntries = Math.min(options.maxEntries ?? 1000, 10000);
|
|
82
|
-
const output = [];
|
|
83
|
-
const walk = async (absolute) => {
|
|
84
|
-
for (const entry of await readdir(absolute, { withFileTypes: true })) {
|
|
85
|
-
if (output.length >= maxEntries)
|
|
86
|
-
return;
|
|
87
|
-
const absoluteEntry = path.join(absolute, entry.name);
|
|
88
|
-
const relative = path
|
|
89
|
-
.relative(this.realRoot, absoluteEntry)
|
|
90
|
-
.split(path.sep)
|
|
91
|
-
.join("/");
|
|
92
|
-
const info = await lstat(absoluteEntry);
|
|
93
|
-
const type = info.isSymbolicLink()
|
|
94
|
-
? "symlink"
|
|
95
|
-
: info.isDirectory()
|
|
96
|
-
? "directory"
|
|
97
|
-
: "file";
|
|
98
|
-
output.push({
|
|
99
|
-
path: relative,
|
|
100
|
-
type,
|
|
101
|
-
size: info.size,
|
|
102
|
-
modifiedAt: info.mtime.toISOString(),
|
|
103
|
-
});
|
|
104
|
-
// Never recurse through symlinks; their targets may point outside the workspace.
|
|
105
|
-
if (options.recursive && entry.isDirectory() && !entry.isSymbolicLink())
|
|
106
|
-
await walk(absoluteEntry);
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
const startInfo = await stat(start);
|
|
110
|
-
if (!startInfo.isDirectory())
|
|
111
|
-
throw new ChatRoomError("INVALID_INPUT", `Not a directory: ${relativePath}`);
|
|
112
|
-
await walk(start);
|
|
113
|
-
return output;
|
|
114
|
-
}
|
|
115
|
-
async search(query, options = {}) {
|
|
116
|
-
if (!query)
|
|
117
|
-
throw new ChatRoomError("INVALID_INPUT", "Search query must not be empty");
|
|
118
|
-
const maxResults = Math.min(options.maxResults ?? 200, 2000);
|
|
119
|
-
const files = await this.list(options.path ?? ".", {
|
|
120
|
-
recursive: true,
|
|
121
|
-
maxEntries: 10000,
|
|
122
|
-
});
|
|
123
|
-
const needle = query.toLocaleLowerCase();
|
|
124
|
-
const results = [];
|
|
125
|
-
for (const file of files) {
|
|
126
|
-
if (results.length >= maxResults ||
|
|
127
|
-
file.type !== "file" ||
|
|
128
|
-
file.size > 2 * 1024 * 1024)
|
|
129
|
-
continue;
|
|
130
|
-
const data = await this.read(file.path, { maxBytes: 2 * 1024 * 1024 });
|
|
131
|
-
if (data.content.includes("\0"))
|
|
132
|
-
continue;
|
|
133
|
-
const lines = data.content.split(/\r?\n/);
|
|
134
|
-
for (let lineIndex = 0; lineIndex < lines.length && results.length < maxResults; lineIndex++) {
|
|
135
|
-
const line = lines[lineIndex] ?? "";
|
|
136
|
-
const haystack = line.toLocaleLowerCase();
|
|
137
|
-
const column = haystack.indexOf(needle);
|
|
138
|
-
if (column !== -1)
|
|
139
|
-
results.push({
|
|
140
|
-
path: file.path,
|
|
141
|
-
line: lineIndex + 1,
|
|
142
|
-
column: column + 1,
|
|
143
|
-
preview: line.slice(0, 500),
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return results;
|
|
148
|
-
}
|
|
149
|
-
async patch(replacements) {
|
|
150
|
-
if (!replacements.length)
|
|
151
|
-
throw new ChatRoomError("INVALID_INPUT", "Patch must contain at least one replacement");
|
|
152
|
-
const grouped = new Map();
|
|
153
|
-
for (const replacement of replacements) {
|
|
154
|
-
if (!replacement.oldText)
|
|
155
|
-
throw new ChatRoomError("INVALID_INPUT", `oldText must not be empty: ${replacement.path}`);
|
|
156
|
-
const key = this.normalizeRelative(replacement.path);
|
|
157
|
-
grouped.set(key, [...(grouped.get(key) ?? []), replacement]);
|
|
158
|
-
}
|
|
159
|
-
// Phase 1 prepares every file completely before any destination is changed.
|
|
160
|
-
const prepared = [];
|
|
161
|
-
let replacementCount = 0;
|
|
162
|
-
let bytesBefore = 0;
|
|
163
|
-
let bytesAfter = 0;
|
|
164
|
-
for (const [relative, edits] of grouped) {
|
|
165
|
-
const target = await this.resolveWritable(relative, false);
|
|
166
|
-
const mode = (await lstat(target)).mode & 0o777;
|
|
167
|
-
const before = (await this.read(relative, { maxBytes: 64 * 1024 * 1024 }))
|
|
168
|
-
.content;
|
|
169
|
-
let after = before;
|
|
170
|
-
for (const edit of edits) {
|
|
171
|
-
const occurrences = countOccurrences(after, edit.oldText);
|
|
172
|
-
if (occurrences === 0)
|
|
173
|
-
throw new ChatRoomError("CONFLICT", `oldText not found in ${relative}`);
|
|
174
|
-
if (edit.occurrence === "all") {
|
|
175
|
-
after = after.split(edit.oldText).join(edit.newText);
|
|
176
|
-
replacementCount += occurrences;
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
const occurrence = edit.occurrence ?? 1;
|
|
180
|
-
if (occurrence < 1 || occurrence > occurrences)
|
|
181
|
-
throw new ChatRoomError("CONFLICT", `Occurrence ${occurrence} not found in ${relative}`, { occurrences });
|
|
182
|
-
after = replaceOccurrence(after, edit.oldText, edit.newText, occurrence);
|
|
183
|
-
replacementCount += 1;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
bytesBefore += Buffer.byteLength(before);
|
|
187
|
-
bytesAfter += Buffer.byteLength(after);
|
|
188
|
-
const id = randomUUID();
|
|
189
|
-
prepared.push({
|
|
190
|
-
relative,
|
|
191
|
-
target,
|
|
192
|
-
after,
|
|
193
|
-
temp: `${target}.chatroom-${id}.tmp`,
|
|
194
|
-
backup: `${target}.chatroom-${id}.bak`,
|
|
195
|
-
mode,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
for (const item of prepared)
|
|
200
|
-
await writeFile(item.temp, item.after, { flag: "wx", mode: item.mode });
|
|
201
|
-
}
|
|
202
|
-
catch (error) {
|
|
203
|
-
for (const item of prepared)
|
|
204
|
-
await rm(item.temp, { force: true }).catch(() => undefined);
|
|
205
|
-
throw error;
|
|
206
|
-
}
|
|
207
|
-
// Phase 2 commits with backups so already-swapped files can be restored if a later rename fails.
|
|
208
|
-
const committed = [];
|
|
209
|
-
try {
|
|
210
|
-
for (const item of prepared) {
|
|
211
|
-
await rename(item.target, item.backup);
|
|
212
|
-
try {
|
|
213
|
-
await rename(item.temp, item.target);
|
|
214
|
-
committed.push(item);
|
|
215
|
-
}
|
|
216
|
-
catch (error) {
|
|
217
|
-
await rename(item.backup, item.target).catch(() => undefined);
|
|
218
|
-
throw error;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
catch (error) {
|
|
223
|
-
for (const item of committed.reverse()) {
|
|
224
|
-
await rm(item.target, { force: true }).catch(() => undefined);
|
|
225
|
-
await rename(item.backup, item.target).catch(() => undefined);
|
|
226
|
-
}
|
|
227
|
-
throw new ChatRoomError("INTERNAL", "Patch commit failed and was rolled back", undefined, {
|
|
228
|
-
cause: error,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
finally {
|
|
232
|
-
for (const item of prepared) {
|
|
233
|
-
await rm(item.temp, { force: true }).catch(() => undefined);
|
|
234
|
-
await rm(item.backup, { force: true }).catch(() => undefined);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return {
|
|
238
|
-
files: prepared.map((item) => item.relative),
|
|
239
|
-
replacements: replacementCount,
|
|
240
|
-
bytesBefore,
|
|
241
|
-
bytesAfter,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
normalizeRelative(input) {
|
|
245
|
-
// Check both host-native and Windows path syntax so a path cannot become dangerous when moved across platforms.
|
|
246
|
-
if (typeof input !== "string" || input.includes("\0"))
|
|
247
|
-
throw new ChatRoomError("INVALID_INPUT", "Path must be a valid string");
|
|
248
|
-
if (path.isAbsolute(input) ||
|
|
249
|
-
path.win32.isAbsolute(input) ||
|
|
250
|
-
/^\\\\/.test(input))
|
|
251
|
-
throw new ChatRoomError("FORBIDDEN", "Absolute paths are not allowed");
|
|
252
|
-
const parts = input
|
|
253
|
-
.split(/[\\/]+/)
|
|
254
|
-
.filter((part) => part !== "" && part !== ".");
|
|
255
|
-
if (parts.some((part) => part === ".."))
|
|
256
|
-
throw new ChatRoomError("FORBIDDEN", "Path traversal is not allowed");
|
|
257
|
-
return parts.join("/") || ".";
|
|
258
|
-
}
|
|
259
|
-
lexical(relativePath) {
|
|
260
|
-
const normalized = this.normalizeRelative(relativePath);
|
|
261
|
-
const absolute = path.resolve(this.realRoot, ...normalized.split("/"));
|
|
262
|
-
if (!inside(this.realRoot, absolute))
|
|
263
|
-
throw new ChatRoomError("FORBIDDEN", "Path escapes workspace");
|
|
264
|
-
return absolute;
|
|
265
|
-
}
|
|
266
|
-
async resolveReadable(relativePath) {
|
|
267
|
-
const lexical = this.lexical(relativePath);
|
|
268
|
-
const canonical = await realpath(lexical).catch((error) => {
|
|
269
|
-
if (error.code === "ENOENT")
|
|
270
|
-
throw new ChatRoomError("NOT_FOUND", `Path does not exist: ${relativePath}`);
|
|
271
|
-
throw error;
|
|
272
|
-
});
|
|
273
|
-
// realpath resolves symlinks before containment is checked against the canonical workspace root.
|
|
274
|
-
if (!inside(this.realRoot, canonical))
|
|
275
|
-
throw new ChatRoomError("FORBIDDEN", "Symlink escapes workspace");
|
|
276
|
-
return canonical;
|
|
277
|
-
}
|
|
278
|
-
async resolveWritable(relativePath, createParents) {
|
|
279
|
-
const target = this.lexical(relativePath);
|
|
280
|
-
const relative = path.relative(this.realRoot, target);
|
|
281
|
-
const parts = relative.split(path.sep).filter(Boolean);
|
|
282
|
-
let current = this.realRoot;
|
|
283
|
-
// Walk each existing parent explicitly so writes cannot traverse a symlinked directory.
|
|
284
|
-
for (let index = 0; index < parts.length - 1; index++) {
|
|
285
|
-
current = path.join(current, parts[index]);
|
|
286
|
-
try {
|
|
287
|
-
const info = await lstat(current);
|
|
288
|
-
if (info.isSymbolicLink())
|
|
289
|
-
throw new ChatRoomError("FORBIDDEN", `Writes through symlinked directories are not allowed: ${relativePath}`);
|
|
290
|
-
if (!info.isDirectory())
|
|
291
|
-
throw new ChatRoomError("INVALID_INPUT", `Parent is not a directory: ${parts[index]}`);
|
|
292
|
-
const canonical = await realpath(current);
|
|
293
|
-
if (!inside(this.realRoot, canonical))
|
|
294
|
-
throw new ChatRoomError("FORBIDDEN", "Write path escapes workspace");
|
|
295
|
-
}
|
|
296
|
-
catch (error) {
|
|
297
|
-
if (error instanceof ChatRoomError)
|
|
298
|
-
throw error;
|
|
299
|
-
if (error.code !== "ENOENT")
|
|
300
|
-
throw error;
|
|
301
|
-
if (!createParents)
|
|
302
|
-
throw new ChatRoomError("NOT_FOUND", `Parent directory does not exist: ${relativePath}`);
|
|
303
|
-
await mkdir(current, { recursive: false, mode: 0o700 });
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
try {
|
|
307
|
-
const targetInfo = await lstat(target);
|
|
308
|
-
if (targetInfo.isSymbolicLink())
|
|
309
|
-
throw new ChatRoomError("FORBIDDEN", `Writes through symlinks are not allowed: ${relativePath}`);
|
|
310
|
-
}
|
|
311
|
-
catch (error) {
|
|
312
|
-
if (error instanceof ChatRoomError)
|
|
313
|
-
throw error;
|
|
314
|
-
if (error.code !== "ENOENT")
|
|
315
|
-
throw error;
|
|
316
|
-
}
|
|
317
|
-
return target;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
function inside(root, candidate) {
|
|
321
|
-
const rel = path.relative(root, candidate);
|
|
322
|
-
return (rel === "" ||
|
|
323
|
-
(!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel)));
|
|
324
|
-
}
|
|
325
|
-
function countOccurrences(text, needle) {
|
|
326
|
-
let count = 0, index = 0;
|
|
327
|
-
while ((index = text.indexOf(needle, index)) !== -1) {
|
|
328
|
-
count++;
|
|
329
|
-
index += needle.length;
|
|
330
|
-
}
|
|
331
|
-
return count;
|
|
332
|
-
}
|
|
333
|
-
function replaceOccurrence(text, needle, replacement, occurrence) {
|
|
334
|
-
let index = -1, from = 0;
|
|
335
|
-
for (let i = 0; i < occurrence; i++) {
|
|
336
|
-
index = text.indexOf(needle, from);
|
|
337
|
-
from = index + needle.length;
|
|
338
|
-
}
|
|
339
|
-
return `${text.slice(0, index)}${replacement}${text.slice(index + needle.length)}`;
|
|
340
|
-
}
|