@chatroomcp/chatroom 0.2.0 → 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/computer/computer-native-host.d.ts +2 -1
- package/dist/plugins/computer/computer-native-host.js +107 -17
- package/dist/plugins/computer/computer-schemas.d.ts +1 -0
- package/dist/plugins/computer/computer-schemas.js +1 -1
- package/dist/plugins/computer/types.d.ts +1 -1
- 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-BXlxEk8f.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-DUM6zVHS.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,7 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { WorkspaceFile } from "./types.js";
|
|
2
2
|
export declare class WorkspaceFs {
|
|
3
3
|
readonly root: string;
|
|
4
|
-
private readonly realRoot;
|
|
5
4
|
private constructor();
|
|
6
5
|
static create(root: string): Promise<WorkspaceFs>;
|
|
7
6
|
read(relativePath: string, options?: {
|
|
@@ -18,21 +17,9 @@ export declare class WorkspaceFs {
|
|
|
18
17
|
bytes: number;
|
|
19
18
|
truncated: boolean;
|
|
20
19
|
}>;
|
|
21
|
-
write(relativePath: string, content: string): Promise<{
|
|
22
|
-
path: string;
|
|
23
|
-
bytes: number;
|
|
24
|
-
}>;
|
|
25
20
|
list(relativePath?: string, options?: {
|
|
26
21
|
recursive?: boolean;
|
|
27
22
|
maxEntries?: number;
|
|
28
|
-
}): Promise<
|
|
29
|
-
search(query: string, options?: {
|
|
30
|
-
path?: string;
|
|
31
|
-
maxResults?: number;
|
|
32
|
-
}): Promise<SearchMatch[]>;
|
|
33
|
-
patch(replacements: PatchReplacement[]): Promise<ChangeSet>;
|
|
34
|
-
private normalizeRelative;
|
|
35
|
-
private lexical;
|
|
23
|
+
}): Promise<WorkspaceFile[]>;
|
|
36
24
|
private resolveReadable;
|
|
37
|
-
private resolveWritable;
|
|
38
25
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ChatRoomError } from "../../core/errors/chatroom-error.js";
|
|
4
|
+
export class WorkspaceFs {
|
|
5
|
+
root;
|
|
6
|
+
constructor(root) {
|
|
7
|
+
this.root = root;
|
|
8
|
+
}
|
|
9
|
+
static async create(root) {
|
|
10
|
+
const canonical = await realpath(path.resolve(root)).catch(() => {
|
|
11
|
+
throw new ChatRoomError("NOT_FOUND", `Workspace root does not exist: ${root}`);
|
|
12
|
+
});
|
|
13
|
+
const info = await stat(canonical);
|
|
14
|
+
if (!info.isDirectory())
|
|
15
|
+
throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${root}`);
|
|
16
|
+
return new WorkspaceFs(canonical);
|
|
17
|
+
}
|
|
18
|
+
async read(relativePath, options = {}) {
|
|
19
|
+
const result = await this.readBytes(relativePath, options);
|
|
20
|
+
return {
|
|
21
|
+
content: result.data.toString("utf8"),
|
|
22
|
+
bytes: result.bytes,
|
|
23
|
+
truncated: result.truncated,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async readBytes(relativePath, options = {}) {
|
|
27
|
+
const target = await this.resolveReadable(relativePath);
|
|
28
|
+
const info = await stat(target);
|
|
29
|
+
if (!info.isFile())
|
|
30
|
+
throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
|
|
31
|
+
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
32
|
+
const handle = await open(target, "r");
|
|
33
|
+
try {
|
|
34
|
+
const readSize = Math.min(info.size, maxBytes);
|
|
35
|
+
const buffer = Buffer.alloc(readSize);
|
|
36
|
+
const result = await handle.read(buffer, 0, readSize, 0);
|
|
37
|
+
return {
|
|
38
|
+
data: buffer.subarray(0, result.bytesRead),
|
|
39
|
+
bytes: info.size,
|
|
40
|
+
truncated: info.size > maxBytes,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async list(relativePath = ".", options = {}) {
|
|
48
|
+
const start = await this.resolveReadable(relativePath);
|
|
49
|
+
if (!(await stat(start)).isDirectory())
|
|
50
|
+
throw new ChatRoomError("INVALID_INPUT", `Not a directory: ${relativePath}`);
|
|
51
|
+
const maxEntries = Math.min(options.maxEntries ?? 1000, 10000);
|
|
52
|
+
const output = [];
|
|
53
|
+
const walk = async (directory) => {
|
|
54
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
55
|
+
if (output.length >= maxEntries)
|
|
56
|
+
return;
|
|
57
|
+
const absolute = path.join(directory, entry.name);
|
|
58
|
+
const info = await lstat(absolute);
|
|
59
|
+
const type = info.isSymbolicLink()
|
|
60
|
+
? "symlink"
|
|
61
|
+
: info.isDirectory()
|
|
62
|
+
? "directory"
|
|
63
|
+
: "file";
|
|
64
|
+
output.push({
|
|
65
|
+
path: path.relative(this.root, absolute).split(path.sep).join("/"),
|
|
66
|
+
type,
|
|
67
|
+
size: info.size,
|
|
68
|
+
});
|
|
69
|
+
if (options.recursive && entry.isDirectory() && !entry.isSymbolicLink())
|
|
70
|
+
await walk(absolute);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
await walk(start);
|
|
74
|
+
return output;
|
|
75
|
+
}
|
|
76
|
+
async resolveReadable(relativePath) {
|
|
77
|
+
const normalized = normalizeRelative(relativePath);
|
|
78
|
+
const lexical = path.resolve(this.root, ...normalized.split("/"));
|
|
79
|
+
if (!inside(this.root, lexical))
|
|
80
|
+
throw new ChatRoomError("FORBIDDEN", "Path escapes workspace");
|
|
81
|
+
const canonical = await realpath(lexical).catch((error) => {
|
|
82
|
+
if (error.code === "ENOENT")
|
|
83
|
+
throw new ChatRoomError("NOT_FOUND", `Path does not exist: ${relativePath}`);
|
|
84
|
+
throw error;
|
|
85
|
+
});
|
|
86
|
+
if (!inside(this.root, canonical))
|
|
87
|
+
throw new ChatRoomError("FORBIDDEN", "Symlink escapes workspace");
|
|
88
|
+
return canonical;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function normalizeRelative(input) {
|
|
92
|
+
if (typeof input !== "string" || input.includes("\0"))
|
|
93
|
+
throw new ChatRoomError("INVALID_INPUT", "Path must be a valid string");
|
|
94
|
+
if (path.isAbsolute(input) ||
|
|
95
|
+
path.win32.isAbsolute(input) ||
|
|
96
|
+
/^\\\\/.test(input))
|
|
97
|
+
throw new ChatRoomError("FORBIDDEN", "Absolute paths are not allowed");
|
|
98
|
+
const parts = input
|
|
99
|
+
.split(/[\\/]+/)
|
|
100
|
+
.filter((part) => part !== "" && part !== ".");
|
|
101
|
+
if (parts.some((part) => part === ".."))
|
|
102
|
+
throw new ChatRoomError("FORBIDDEN", "Path traversal is not allowed");
|
|
103
|
+
return parts.join("/") || ".";
|
|
104
|
+
}
|
|
105
|
+
function inside(root, candidate) {
|
|
106
|
+
const relative = path.relative(root, candidate);
|
|
107
|
+
return (relative === "" ||
|
|
108
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
109
|
+
relative !== ".." &&
|
|
110
|
+
!path.isAbsolute(relative)));
|
|
111
|
+
}
|
|
@@ -1,46 +1,11 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
3
|
-
import { WorkspaceFs } from "./infrastructure/workspace-fs.js";
|
|
4
|
-
import type { GitService } from "./git/git-service.js";
|
|
5
|
-
import type { WorktreeReviewService } from "./worktree-review-service.js";
|
|
6
|
-
interface OpenWorkspaceRequest {
|
|
7
|
-
path: string;
|
|
8
|
-
mode?: WorkspaceMode;
|
|
9
|
-
}
|
|
1
|
+
import type { WorkspaceEntry, WorkspaceInfo } from "./types.js";
|
|
2
|
+
import { WorkspaceFs } from "./workspace-fs.js";
|
|
10
3
|
export declare class WorkspaceService {
|
|
11
|
-
private readonly repository;
|
|
12
|
-
private readonly git;
|
|
13
|
-
private readonly review;
|
|
14
4
|
private readonly allowedRoots;
|
|
15
|
-
private readonly dataDir;
|
|
16
|
-
private readonly pending;
|
|
17
|
-
private readonly filesystems;
|
|
18
|
-
private readonly workspaces;
|
|
19
5
|
private constructor();
|
|
20
|
-
static create(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
private discoverGitWorkspace;
|
|
26
|
-
markUsed(workspaceId: string): Workspace;
|
|
27
|
-
fs(workspaceId: string): Promise<WorkspaceFs>;
|
|
28
|
-
refresh(workspaceId: string): Promise<Workspace>;
|
|
29
|
-
remove(workspaceId: string, force?: boolean): Promise<Workspace>;
|
|
30
|
-
previewWorktreeApply(workspaceId: string): Promise<import("./domain/review.js").WorktreeApplyPreview>;
|
|
31
|
-
previewWorktreeFileDiff(workspaceId: string, filePath: string): Promise<import("./domain/review.js").WorktreeFileDiff>;
|
|
32
|
-
applyWorktree(workspaceId: string, paths?: string[]): Promise<{
|
|
33
|
-
workspaceId: string;
|
|
34
|
-
bytes: number;
|
|
35
|
-
paths: string[];
|
|
36
|
-
conflicts: string[];
|
|
37
|
-
}>;
|
|
38
|
-
private openLocked;
|
|
39
|
-
private createWorkspace;
|
|
40
|
-
private restorePersistedWorkspaces;
|
|
41
|
-
private isRecordValid;
|
|
42
|
-
private reconcileManagedWorktrees;
|
|
43
|
-
private removeManagedContainer;
|
|
44
|
-
private validateSourceRoot;
|
|
6
|
+
static create(allowedRoots: string[]): Promise<WorkspaceService>;
|
|
7
|
+
list(): Promise<WorkspaceEntry[]>;
|
|
8
|
+
resolve(input: string): Promise<string>;
|
|
9
|
+
info(input: string): Promise<WorkspaceInfo>;
|
|
10
|
+
fs(input: string): Promise<WorkspaceFs>;
|
|
45
11
|
}
|
|
46
|
-
export {};
|
|
@@ -1,339 +1,85 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Workspace lifetime is deliberately independent from MCP or HTTP connection lifetime.
|
|
4
|
-
*/
|
|
5
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
6
|
-
import { mkdir, readdir, realpath, rm } from "node:fs/promises";
|
|
1
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
7
3
|
import path from "node:path";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
4
|
import { ChatRoomError } from "../../core/errors/chatroom-error.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
5
|
+
import { readInstructions, readSkills } from "./metadata.js";
|
|
6
|
+
import { WorkspaceFs } from "./workspace-fs.js";
|
|
12
7
|
export class WorkspaceService {
|
|
13
|
-
repository;
|
|
14
|
-
git;
|
|
15
|
-
review;
|
|
16
8
|
allowedRoots;
|
|
17
|
-
|
|
18
|
-
pending = new Map();
|
|
19
|
-
filesystems = new Map();
|
|
20
|
-
workspaces = new Map();
|
|
21
|
-
constructor(repository, git, review, allowedRoots, dataDir) {
|
|
22
|
-
this.repository = repository;
|
|
23
|
-
this.git = git;
|
|
24
|
-
this.review = review;
|
|
9
|
+
constructor(allowedRoots) {
|
|
25
10
|
this.allowedRoots = allowedRoots;
|
|
26
|
-
this.dataDir = dataDir;
|
|
27
11
|
}
|
|
28
|
-
static async create(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for (const root of allowedRoots) {
|
|
32
|
-
const expanded = expandHome(root);
|
|
33
|
-
const canonical = await realpath(expanded).catch(() => {
|
|
12
|
+
static async create(allowedRoots) {
|
|
13
|
+
const canonicalRoots = await Promise.all(allowedRoots.map(async (root) => {
|
|
14
|
+
const canonical = await realpath(expandHome(root)).catch(() => {
|
|
34
15
|
throw new ChatRoomError("NOT_FOUND", `Allowed root does not exist: ${root}`);
|
|
35
16
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return service;
|
|
46
|
-
}
|
|
47
|
-
async open(request) {
|
|
48
|
-
const mode = request.mode ?? "checkout";
|
|
49
|
-
const sourceRoot = await this.validateSourceRoot(request.path);
|
|
50
|
-
if (mode === "worktree")
|
|
51
|
-
return this.openLocked(sourceRoot, mode);
|
|
52
|
-
const key = `checkout:${sourceRoot}`;
|
|
53
|
-
// Coalesce concurrent opens of the same logical workspace instead of racing worktree creation or persistence.
|
|
54
|
-
const existingPending = this.pending.get(key);
|
|
55
|
-
if (existingPending)
|
|
56
|
-
return existingPending;
|
|
57
|
-
const operation = this.openLocked(sourceRoot, mode).finally(() => this.pending.delete(key));
|
|
58
|
-
this.pending.set(key, operation);
|
|
59
|
-
return operation;
|
|
60
|
-
}
|
|
61
|
-
get(workspaceId) {
|
|
62
|
-
const workspace = this.workspaces.get(workspaceId);
|
|
63
|
-
if (!workspace)
|
|
64
|
-
throw new ChatRoomError("NOT_FOUND", `Unknown WorkspaceId: ${workspaceId}`);
|
|
65
|
-
if (!this.isRecordValid(workspace)) {
|
|
66
|
-
this.filesystems.delete(workspace.id);
|
|
67
|
-
this.workspaces.delete(workspace.id);
|
|
68
|
-
this.repository.remove(workspace.id);
|
|
69
|
-
throw new ChatRoomError("NOT_FOUND", `Workspace no longer exists: ${workspaceId}`);
|
|
70
|
-
}
|
|
71
|
-
return workspace;
|
|
72
|
-
}
|
|
73
|
-
list() {
|
|
74
|
-
const valid = [];
|
|
75
|
-
for (const workspace of this.workspaces.values()) {
|
|
76
|
-
if (this.isRecordValid(workspace)) {
|
|
77
|
-
valid.push(workspace);
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
this.filesystems.delete(workspace.id);
|
|
81
|
-
this.workspaces.delete(workspace.id);
|
|
82
|
-
this.repository.remove(workspace.id);
|
|
83
|
-
}
|
|
84
|
-
return valid.sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt));
|
|
85
|
-
}
|
|
86
|
-
async discoverGitWorkspaces() {
|
|
87
|
-
for (const root of this.allowedRoots) {
|
|
88
|
-
await this.discoverGitWorkspace(root);
|
|
17
|
+
if (!(await stat(canonical)).isDirectory())
|
|
18
|
+
throw new ChatRoomError("INVALID_INPUT", `Allowed root is not a directory: ${root}`);
|
|
19
|
+
return canonical;
|
|
20
|
+
}));
|
|
21
|
+
return new WorkspaceService([...new Set(canonicalRoots)]);
|
|
22
|
+
}
|
|
23
|
+
async list() {
|
|
24
|
+
const roots = new Set();
|
|
25
|
+
for (const allowedRoot of this.allowedRoots) {
|
|
89
26
|
let entries;
|
|
90
27
|
try {
|
|
91
|
-
entries = await readdir(
|
|
28
|
+
entries = await readdir(allowedRoot, { withFileTypes: true });
|
|
92
29
|
}
|
|
93
30
|
catch {
|
|
94
31
|
continue;
|
|
95
32
|
}
|
|
96
33
|
for (const entry of entries) {
|
|
97
|
-
if (!entry.isDirectory() ||
|
|
34
|
+
if (!entry.isDirectory() ||
|
|
35
|
+
entry.isSymbolicLink() ||
|
|
36
|
+
entry.name.startsWith("."))
|
|
98
37
|
continue;
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return this.list();
|
|
103
|
-
}
|
|
104
|
-
async discoverGitWorkspace(candidate) {
|
|
105
|
-
if (!(await exists(path.join(candidate, ".git"))))
|
|
106
|
-
return;
|
|
107
|
-
const existing = [...this.workspaces.values()].find((workspace) => workspace.root === candidate && workspace.mode === "checkout");
|
|
108
|
-
if (existing && this.isRecordValid(existing))
|
|
109
|
-
return;
|
|
110
|
-
if (existing) {
|
|
111
|
-
this.workspaces.delete(existing.id);
|
|
112
|
-
this.repository.remove(existing.id);
|
|
113
|
-
}
|
|
114
|
-
await this.createWorkspace(candidate, candidate, "checkout");
|
|
115
|
-
}
|
|
116
|
-
markUsed(workspaceId) {
|
|
117
|
-
const workspace = this.get(workspaceId);
|
|
118
|
-
workspace.lastUsedAt = new Date().toISOString();
|
|
119
|
-
this.repository.upsert(toRecord(workspace));
|
|
120
|
-
return workspace;
|
|
121
|
-
}
|
|
122
|
-
async fs(workspaceId) {
|
|
123
|
-
const workspace = this.markUsed(workspaceId);
|
|
124
|
-
const existing = this.filesystems.get(workspaceId);
|
|
125
|
-
if (existing)
|
|
126
|
-
return existing;
|
|
127
|
-
const filesystem = await WorkspaceFs.create(workspace.root);
|
|
128
|
-
this.filesystems.set(workspaceId, filesystem);
|
|
129
|
-
return filesystem;
|
|
130
|
-
}
|
|
131
|
-
async refresh(workspaceId) {
|
|
132
|
-
const workspace = this.get(workspaceId);
|
|
133
|
-
const fs = await this.fs(workspaceId);
|
|
134
|
-
const gitInfo = await this.git.info(workspace.root);
|
|
135
|
-
workspace.instructions = await discoverInstructions(fs);
|
|
136
|
-
workspace.skills = await discoverSkills(fs);
|
|
137
|
-
workspace.capabilities = {
|
|
138
|
-
filesystem: "read-write",
|
|
139
|
-
git: gitInfo.isRepository,
|
|
140
|
-
skills: workspace.skills.length > 0,
|
|
141
|
-
};
|
|
142
|
-
return workspace;
|
|
143
|
-
}
|
|
144
|
-
async remove(workspaceId, force = false) {
|
|
145
|
-
const workspace = this.get(workspaceId);
|
|
146
|
-
if (workspace.mode === "checkout") {
|
|
147
|
-
this.filesystems.delete(workspace.id);
|
|
148
|
-
this.workspaces.delete(workspace.id);
|
|
149
|
-
this.repository.remove(workspace.id);
|
|
150
|
-
return workspace;
|
|
151
|
-
}
|
|
152
|
-
const gitInfo = await this.git.info(workspace.root);
|
|
153
|
-
if (gitInfo.dirty && !force)
|
|
154
|
-
throw new ChatRoomError("CONFLICT", "Worktree contains uncommitted changes");
|
|
155
|
-
await this.git.removeWorktree(workspace.sourceRoot, workspace.root, force);
|
|
156
|
-
await this.git.pruneWorktrees(workspace.sourceRoot).catch(() => undefined);
|
|
157
|
-
this.filesystems.delete(workspace.id);
|
|
158
|
-
this.workspaces.delete(workspace.id);
|
|
159
|
-
this.repository.remove(workspace.id);
|
|
160
|
-
await this.removeManagedContainer(workspace.root);
|
|
161
|
-
return workspace;
|
|
162
|
-
}
|
|
163
|
-
async previewWorktreeApply(workspaceId) {
|
|
164
|
-
return this.review.preview(this.get(workspaceId));
|
|
165
|
-
}
|
|
166
|
-
async previewWorktreeFileDiff(workspaceId, filePath) {
|
|
167
|
-
return this.review.fileDiff(this.get(workspaceId), filePath);
|
|
168
|
-
}
|
|
169
|
-
async applyWorktree(workspaceId, paths) {
|
|
170
|
-
const workspace = this.get(workspaceId);
|
|
171
|
-
const result = await this.review.apply(workspace, paths);
|
|
172
|
-
this.markUsed(workspace.id);
|
|
173
|
-
return { workspaceId: workspace.id, ...result };
|
|
174
|
-
}
|
|
175
|
-
async openLocked(sourceRoot, mode) {
|
|
176
|
-
if (mode === "checkout") {
|
|
177
|
-
const existing = [...this.workspaces.values()].find((workspace) => workspace.root === sourceRoot && workspace.mode === "checkout");
|
|
178
|
-
if (existing) {
|
|
179
|
-
this.filesystems.set(existing.id, await WorkspaceFs.create(existing.root));
|
|
180
|
-
return this.refresh(existing.id);
|
|
38
|
+
const candidate = await realpath(path.join(allowedRoot, entry.name)).catch(() => null);
|
|
39
|
+
if (candidate && inside(allowedRoot, candidate))
|
|
40
|
+
roots.add(candidate);
|
|
181
41
|
}
|
|
182
|
-
return this.createWorkspace(sourceRoot, sourceRoot, mode);
|
|
183
|
-
}
|
|
184
|
-
const uuid = randomUUID();
|
|
185
|
-
const id = `ws_${uuid}`;
|
|
186
|
-
const container = path.join(this.dataDir, "worktrees", uuid.replaceAll("-", "").slice(0, 8));
|
|
187
|
-
const target = path.join(container, path.basename(sourceRoot) || "workspace");
|
|
188
|
-
await mkdir(container, { recursive: true, mode: 0o700 });
|
|
189
|
-
let worktreeCreated = false;
|
|
190
|
-
try {
|
|
191
|
-
await this.git.createWorktree(sourceRoot, target);
|
|
192
|
-
worktreeCreated = true;
|
|
193
|
-
return await this.createWorkspace(target, sourceRoot, mode, id);
|
|
194
|
-
}
|
|
195
|
-
catch (error) {
|
|
196
|
-
this.filesystems.delete(id);
|
|
197
|
-
this.workspaces.delete(id);
|
|
198
|
-
this.repository.remove(id);
|
|
199
|
-
if (worktreeCreated)
|
|
200
|
-
await this.git
|
|
201
|
-
.removeWorktree(sourceRoot, target, true)
|
|
202
|
-
.catch(() => undefined);
|
|
203
|
-
await this.git.pruneWorktrees(sourceRoot).catch(() => undefined);
|
|
204
|
-
await rm(container, { recursive: true, force: true }).catch(() => undefined);
|
|
205
|
-
throw error;
|
|
206
42
|
}
|
|
43
|
+
return [...roots]
|
|
44
|
+
.sort((a, b) => a.localeCompare(b))
|
|
45
|
+
.map((root) => ({ root }));
|
|
207
46
|
}
|
|
208
|
-
async
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const gitInfo = await this.git.info(root);
|
|
212
|
-
const now = new Date().toISOString();
|
|
213
|
-
const skills = await discoverSkills(fs);
|
|
214
|
-
const workspace = {
|
|
215
|
-
id,
|
|
216
|
-
root,
|
|
217
|
-
sourceRoot,
|
|
218
|
-
mode,
|
|
219
|
-
createdAt: now,
|
|
220
|
-
lastUsedAt: now,
|
|
221
|
-
instructions: await discoverInstructions(fs),
|
|
222
|
-
skills,
|
|
223
|
-
capabilities: {
|
|
224
|
-
filesystem: "read-write",
|
|
225
|
-
git: gitInfo.isRepository,
|
|
226
|
-
skills: skills.length > 0,
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
this.workspaces.set(workspace.id, workspace);
|
|
230
|
-
this.repository.upsert(toRecord(workspace));
|
|
231
|
-
return workspace;
|
|
232
|
-
}
|
|
233
|
-
async restorePersistedWorkspaces() {
|
|
234
|
-
for (const record of this.repository.list()) {
|
|
235
|
-
if (!this.isRecordValid(record)) {
|
|
236
|
-
this.repository.remove(record.id);
|
|
237
|
-
if (record.mode === "worktree")
|
|
238
|
-
await this.removeManagedContainer(record.root);
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
try {
|
|
242
|
-
const fs = await WorkspaceFs.create(record.root);
|
|
243
|
-
const gitInfo = await this.git.info(record.root);
|
|
244
|
-
const skills = await discoverSkills(fs);
|
|
245
|
-
const workspace = {
|
|
246
|
-
...record,
|
|
247
|
-
instructions: await discoverInstructions(fs),
|
|
248
|
-
skills,
|
|
249
|
-
capabilities: {
|
|
250
|
-
filesystem: "read-write",
|
|
251
|
-
git: gitInfo.isRepository,
|
|
252
|
-
skills: skills.length > 0,
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
this.filesystems.set(record.id, fs);
|
|
256
|
-
this.workspaces.set(record.id, workspace);
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
this.repository.remove(record.id);
|
|
260
|
-
if (record.mode === "worktree")
|
|
261
|
-
await this.removeManagedContainer(record.root);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
isRecordValid(record) {
|
|
266
|
-
if (!existsSync(record.root) || !existsSync(record.sourceRoot))
|
|
267
|
-
return false;
|
|
268
|
-
try {
|
|
269
|
-
const sourceRoot = realpathSync(record.sourceRoot);
|
|
270
|
-
if (!this.allowedRoots.some((root) => inside(root, sourceRoot)))
|
|
271
|
-
return false;
|
|
272
|
-
if (record.mode === "checkout")
|
|
273
|
-
return realpathSync(record.root) === sourceRoot;
|
|
274
|
-
const managedRoot = realpathSync(path.join(this.dataDir, "worktrees"));
|
|
275
|
-
return inside(managedRoot, realpathSync(record.root));
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
async reconcileManagedWorktrees() {
|
|
282
|
-
const pruned = new Set();
|
|
283
|
-
for (const workspace of [...this.workspaces.values()]) {
|
|
284
|
-
if (workspace.mode !== "worktree")
|
|
285
|
-
continue;
|
|
286
|
-
if (await exists(workspace.sourceRoot)) {
|
|
287
|
-
if (!pruned.has(workspace.sourceRoot)) {
|
|
288
|
-
await this.git
|
|
289
|
-
.pruneWorktrees(workspace.sourceRoot)
|
|
290
|
-
.catch(() => undefined);
|
|
291
|
-
pruned.add(workspace.sourceRoot);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (await exists(workspace.root))
|
|
295
|
-
continue;
|
|
296
|
-
this.filesystems.delete(workspace.id);
|
|
297
|
-
this.workspaces.delete(workspace.id);
|
|
298
|
-
this.repository.remove(workspace.id);
|
|
299
|
-
await this.removeManagedContainer(workspace.root);
|
|
300
|
-
}
|
|
301
|
-
const managedRoot = path.join(this.dataDir, "worktrees");
|
|
302
|
-
const knownContainers = new Set([...this.workspaces.values()]
|
|
303
|
-
.filter((workspace) => workspace.mode === "worktree")
|
|
304
|
-
.map((workspace) => path.resolve(path.dirname(workspace.root))));
|
|
305
|
-
for (const entry of await readdir(managedRoot, { withFileTypes: true })) {
|
|
306
|
-
const container = path.resolve(managedRoot, entry.name);
|
|
307
|
-
if (knownContainers.has(container))
|
|
308
|
-
continue;
|
|
309
|
-
if (entry.isSymbolicLink()) {
|
|
310
|
-
await rm(container, { force: true }).catch(() => undefined);
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (entry.isDirectory())
|
|
314
|
-
await rm(container, { recursive: true, force: true });
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
async removeManagedContainer(worktreeRoot) {
|
|
318
|
-
const managedRoot = path.join(this.dataDir, "worktrees");
|
|
319
|
-
if (!inside(managedRoot, worktreeRoot))
|
|
320
|
-
return;
|
|
321
|
-
const container = path.dirname(worktreeRoot);
|
|
322
|
-
if (container === managedRoot)
|
|
323
|
-
return;
|
|
324
|
-
await rm(container, { recursive: true, force: true }).catch(() => undefined);
|
|
325
|
-
}
|
|
326
|
-
async validateSourceRoot(input) {
|
|
327
|
-
if (!input || typeof input !== "string")
|
|
328
|
-
throw new ChatRoomError("INVALID_INPUT", "Workspace path is required");
|
|
47
|
+
async resolve(input) {
|
|
48
|
+
if (typeof input !== "string" || !input.trim())
|
|
49
|
+
throw new ChatRoomError("INVALID_INPUT", "Workspace root is required");
|
|
329
50
|
const canonical = await realpath(expandHome(input)).catch(() => {
|
|
330
|
-
throw new ChatRoomError("NOT_FOUND", `Workspace
|
|
51
|
+
throw new ChatRoomError("NOT_FOUND", `Workspace root does not exist: ${input}`);
|
|
331
52
|
});
|
|
332
|
-
if (!
|
|
333
|
-
throw new ChatRoomError("
|
|
334
|
-
|
|
335
|
-
});
|
|
336
|
-
}
|
|
53
|
+
if (!(await stat(canonical)).isDirectory())
|
|
54
|
+
throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${input}`);
|
|
55
|
+
if (!this.allowedRoots.some((root) => inside(root, canonical)))
|
|
56
|
+
throw new ChatRoomError("FORBIDDEN", "Workspace is outside configured allowedRoots", { root: canonical });
|
|
337
57
|
return canonical;
|
|
338
58
|
}
|
|
59
|
+
async info(input) {
|
|
60
|
+
const root = await this.resolve(input);
|
|
61
|
+
const fs = await WorkspaceFs.create(root);
|
|
62
|
+
const [instructions, skills] = await Promise.all([
|
|
63
|
+
readInstructions(fs),
|
|
64
|
+
readSkills(fs),
|
|
65
|
+
]);
|
|
66
|
+
return { root, instructions, skills };
|
|
67
|
+
}
|
|
68
|
+
async fs(input) {
|
|
69
|
+
return WorkspaceFs.create(await this.resolve(input));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function expandHome(input) {
|
|
73
|
+
if (input === "~")
|
|
74
|
+
return os.homedir();
|
|
75
|
+
if (input.startsWith("~/") || input.startsWith("~\\"))
|
|
76
|
+
return path.join(os.homedir(), input.slice(2));
|
|
77
|
+
return path.resolve(input);
|
|
78
|
+
}
|
|
79
|
+
function inside(root, candidate) {
|
|
80
|
+
const relative = path.relative(root, candidate);
|
|
81
|
+
return (relative === "" ||
|
|
82
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
83
|
+
relative !== ".." &&
|
|
84
|
+
!path.isAbsolute(relative)));
|
|
339
85
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Shared Express helpers for async error propagation and typed ChatRoom error responses. */
|
|
2
1
|
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
|
3
2
|
export declare function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler;
|
|
4
3
|
export declare function errorMiddleware(error: unknown, _req: Request, res: Response, _next: NextFunction): void;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** OAuth and owner-session HTTP routes backed by AuthService rather than protocol-specific persistence logic. */
|
|
2
1
|
import { Router } from "express";
|
|
3
2
|
import type { AuthService } from "../../auth/auth-service.js";
|
|
4
3
|
import type { IngressPolicy } from "../../auth/ingress-policy.js";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** OAuth and owner-session HTTP routes backed by AuthService rather than protocol-specific persistence logic. */
|
|
2
1
|
import { Router } from "express";
|
|
3
2
|
import { asChatRoomError, ChatRoomError, } from "../../core/errors/chatroom-error.js";
|
|
4
3
|
import { escapeHtml, requireString } from "./http-utils.js";
|