@vibe80/vibe80 0.1.1
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/LICENSE +201 -0
- package/README.md +52 -0
- package/bin/vibe80.js +176 -0
- package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
- package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
- package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
- package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
- package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
- package/client/dist/assets/browser-e3WgtMs-.js +8 -0
- package/client/dist/assets/index-CgqGyssr.css +32 -0
- package/client/dist/assets/index-DnwKjoj7.js +706 -0
- package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
- package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/favicon.png +0 -0
- package/client/dist/favicon.svg +35 -0
- package/client/dist/index.html +14 -0
- package/client/index.html +16 -0
- package/client/package.json +34 -0
- package/client/public/favicon.ico +0 -0
- package/client/public/favicon.png +0 -0
- package/client/public/favicon.svg +35 -0
- package/client/public/pwa-192x192.png +0 -0
- package/client/public/pwa-512x512.png +0 -0
- package/client/src/App.jsx +3131 -0
- package/client/src/assets/logo_small.png +0 -0
- package/client/src/assets/vibe80_dark.svg +51 -0
- package/client/src/assets/vibe80_light.svg +50 -0
- package/client/src/components/Chat/ChatComposer.jsx +228 -0
- package/client/src/components/Chat/ChatMessages.jsx +811 -0
- package/client/src/components/Chat/ChatToolbar.jsx +109 -0
- package/client/src/components/Chat/useChatComposer.js +462 -0
- package/client/src/components/Diff/DiffPanel.jsx +129 -0
- package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
- package/client/src/components/Logs/LogsPanel.jsx +80 -0
- package/client/src/components/SessionGate/SessionGate.jsx +874 -0
- package/client/src/components/Settings/SettingsPanel.jsx +212 -0
- package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
- package/client/src/components/Topbar/Topbar.jsx +101 -0
- package/client/src/components/WorktreeTabs.css +419 -0
- package/client/src/components/WorktreeTabs.jsx +604 -0
- package/client/src/hooks/useAttachments.jsx +125 -0
- package/client/src/hooks/useBacklog.js +254 -0
- package/client/src/hooks/useChatClear.js +90 -0
- package/client/src/hooks/useChatCollapse.js +42 -0
- package/client/src/hooks/useChatCommands.js +294 -0
- package/client/src/hooks/useChatExport.js +144 -0
- package/client/src/hooks/useChatMessagesState.js +69 -0
- package/client/src/hooks/useChatSend.js +158 -0
- package/client/src/hooks/useChatSocket.js +1239 -0
- package/client/src/hooks/useDiffNavigation.js +19 -0
- package/client/src/hooks/useExplorerActions.js +1184 -0
- package/client/src/hooks/useGitIdentity.js +114 -0
- package/client/src/hooks/useLayoutMode.js +31 -0
- package/client/src/hooks/useLocalPreferences.js +131 -0
- package/client/src/hooks/useMessageSync.js +30 -0
- package/client/src/hooks/useNotifications.js +132 -0
- package/client/src/hooks/usePaneNavigation.js +67 -0
- package/client/src/hooks/usePanelState.js +13 -0
- package/client/src/hooks/useProviderSelection.js +70 -0
- package/client/src/hooks/useRepoBranchesModels.js +218 -0
- package/client/src/hooks/useRepoStatus.js +350 -0
- package/client/src/hooks/useRpcLogActions.js +19 -0
- package/client/src/hooks/useRpcLogView.js +58 -0
- package/client/src/hooks/useSessionHandoff.js +97 -0
- package/client/src/hooks/useSessionLifecycle.js +287 -0
- package/client/src/hooks/useSessionReset.js +63 -0
- package/client/src/hooks/useSessionResync.js +77 -0
- package/client/src/hooks/useTerminalSession.js +328 -0
- package/client/src/hooks/useToolbarExport.js +27 -0
- package/client/src/hooks/useTurnInterrupt.js +43 -0
- package/client/src/hooks/useVibe80Forms.js +128 -0
- package/client/src/hooks/useWorkspaceAuth.js +932 -0
- package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
- package/client/src/hooks/useWorktrees.js +396 -0
- package/client/src/i18n.jsx +87 -0
- package/client/src/index.css +5147 -0
- package/client/src/locales/en.json +37 -0
- package/client/src/locales/fr.json +321 -0
- package/client/src/main.jsx +16 -0
- package/client/vite.config.js +62 -0
- package/docs/api/asyncapi.json +1511 -0
- package/docs/api/openapi.json +3242 -0
- package/git_hooks/prepare-commit-msg +35 -0
- package/package.json +36 -0
- package/server/package.json +29 -0
- package/server/scripts/rotate-workspace-secret.js +101 -0
- package/server/src/claudeClient.js +454 -0
- package/server/src/clientEvents.js +594 -0
- package/server/src/clientFactory.js +164 -0
- package/server/src/codexClient.js +468 -0
- package/server/src/config.js +27 -0
- package/server/src/helpers.js +138 -0
- package/server/src/index.js +1641 -0
- package/server/src/middleware/auth.js +93 -0
- package/server/src/middleware/debug.js +89 -0
- package/server/src/middleware/errorTypes.js +60 -0
- package/server/src/providerLogger.js +60 -0
- package/server/src/routes/files.js +114 -0
- package/server/src/routes/git.js +183 -0
- package/server/src/routes/health.js +13 -0
- package/server/src/routes/sessions.js +407 -0
- package/server/src/routes/workspaces.js +296 -0
- package/server/src/routes/worktrees.js +993 -0
- package/server/src/runAs.js +458 -0
- package/server/src/runtimeStore.js +32 -0
- package/server/src/services/auth.js +157 -0
- package/server/src/services/claudeThreadDirectory.js +33 -0
- package/server/src/services/session.js +918 -0
- package/server/src/services/workspace.js +858 -0
- package/server/src/storage/index.js +17 -0
- package/server/src/storage/redis.js +412 -0
- package/server/src/storage/sqlite.js +649 -0
- package/server/src/worktreeManager.js +717 -0
- package/server/tests/README.md +13 -0
- package/server/tests/factories/workspaceFactory.js +13 -0
- package/server/tests/fixtures/workspaceCredentials.json +4 -0
- package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
- package/server/tests/setup/env.js +9 -0
- package/server/tests/unit/helpers.test.js +95 -0
- package/server/tests/unit/services/auth.test.js +181 -0
- package/server/tests/unit/services/workspace.test.js +115 -0
- package/server/vitest.config.js +23 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import jwt from "jsonwebtoken";
|
|
5
|
+
|
|
6
|
+
const jwtKeyPath = process.env.JWT_KEY_PATH || "/var/lib/vibe80/jwt.key";
|
|
7
|
+
const jwtIssuer = process.env.JWT_ISSUER || "vibe80";
|
|
8
|
+
const jwtAudience = process.env.JWT_AUDIENCE || "workspace";
|
|
9
|
+
const accessTokenTtlSeconds =
|
|
10
|
+
Number(process.env.ACCESS_TOKEN_TTL_SECONDS) || 60 * 60;
|
|
11
|
+
|
|
12
|
+
const loadJwtKey = () => {
|
|
13
|
+
if (process.env.JWT_KEY) {
|
|
14
|
+
return process.env.JWT_KEY;
|
|
15
|
+
}
|
|
16
|
+
if (fs.existsSync(jwtKeyPath)) {
|
|
17
|
+
return fs.readFileSync(jwtKeyPath, "utf8").trim();
|
|
18
|
+
}
|
|
19
|
+
fs.mkdirSync(path.dirname(jwtKeyPath), { recursive: true, mode: 0o700 });
|
|
20
|
+
const key = crypto.randomBytes(32).toString("hex");
|
|
21
|
+
fs.writeFileSync(jwtKeyPath, key, { mode: 0o600 });
|
|
22
|
+
return key;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const jwtKey = loadJwtKey();
|
|
26
|
+
|
|
27
|
+
export const createWorkspaceToken = (workspaceId) =>
|
|
28
|
+
jwt.sign({}, jwtKey, {
|
|
29
|
+
algorithm: "HS256",
|
|
30
|
+
expiresIn: `${accessTokenTtlSeconds}s`,
|
|
31
|
+
subject: workspaceId,
|
|
32
|
+
issuer: jwtIssuer,
|
|
33
|
+
audience: jwtAudience,
|
|
34
|
+
jwtid:
|
|
35
|
+
typeof crypto.randomUUID === "function"
|
|
36
|
+
? crypto.randomUUID()
|
|
37
|
+
: crypto.randomBytes(8).toString("hex"),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const verifyWorkspaceToken = (token) => {
|
|
41
|
+
const payload = jwt.verify(token, jwtKey, {
|
|
42
|
+
issuer: jwtIssuer,
|
|
43
|
+
audience: jwtAudience,
|
|
44
|
+
});
|
|
45
|
+
const workspaceId = payload?.sub;
|
|
46
|
+
if (typeof workspaceId !== "string") {
|
|
47
|
+
throw new Error("Invalid token subject.");
|
|
48
|
+
}
|
|
49
|
+
return workspaceId;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export { accessTokenTtlSeconds };
|
|
53
|
+
|
|
54
|
+
export const isPublicApiRequest = (req) => {
|
|
55
|
+
if (req.method === "POST" && req.path === "/workspaces") {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (req.method === "POST" && req.path === "/workspaces/login") {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (req.method === "POST" && req.path === "/workspaces/refresh") {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "POST" && req.path === "/sessions/handoff/consume") {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === "GET" && req.path === "/health") {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function authMiddleware(req, res, next) {
|
|
74
|
+
if (req.method === "OPTIONS" || isPublicApiRequest(req)) {
|
|
75
|
+
next();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const header = req.headers.authorization || "";
|
|
79
|
+
const bearerToken = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
|
|
80
|
+
const queryToken = typeof req.query.token === "string" ? req.query.token : "";
|
|
81
|
+
const token = bearerToken || queryToken;
|
|
82
|
+
if (!token) {
|
|
83
|
+
res.status(401).json({ error: "Missing workspace token." });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
req.workspaceId = verifyWorkspaceToken(token);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
res.status(401).json({ error: "Invalid workspace token." });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
next();
|
|
93
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createDebugId, formatDebugPayload } from "../helpers.js";
|
|
2
|
+
|
|
3
|
+
const debugApiWsLog = /^(1|true|yes|on)$/i.test(
|
|
4
|
+
process.env.DEBUG_API_WS_LOG || ""
|
|
5
|
+
);
|
|
6
|
+
const debugLogMaxBody = Number.isFinite(Number(process.env.DEBUG_API_WS_LOG_MAX_BODY))
|
|
7
|
+
? Number(process.env.DEBUG_API_WS_LOG_MAX_BODY)
|
|
8
|
+
: 2000;
|
|
9
|
+
|
|
10
|
+
export { debugApiWsLog };
|
|
11
|
+
|
|
12
|
+
export const logDebug = (...args) => {
|
|
13
|
+
if (!debugApiWsLog) return;
|
|
14
|
+
console.log(...args);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const attachWebSocketDebug = (socket, req, label) => {
|
|
18
|
+
if (!debugApiWsLog) return;
|
|
19
|
+
const connectionId = createDebugId();
|
|
20
|
+
const url = req?.url || "";
|
|
21
|
+
console.log("[debug] ws connected", { id: connectionId, label, url });
|
|
22
|
+
|
|
23
|
+
socket.on("message", (data) => {
|
|
24
|
+
console.log("[debug] ws recv", {
|
|
25
|
+
id: connectionId,
|
|
26
|
+
label,
|
|
27
|
+
data: formatDebugPayload(data, debugLogMaxBody),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const originalSend = socket.send.bind(socket);
|
|
32
|
+
socket.send = (data, ...args) => {
|
|
33
|
+
console.log("[debug] ws send", {
|
|
34
|
+
id: connectionId,
|
|
35
|
+
label,
|
|
36
|
+
data: formatDebugPayload(data, debugLogMaxBody),
|
|
37
|
+
});
|
|
38
|
+
return originalSend(data, ...args);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
socket.on("close", (code, reason) => {
|
|
42
|
+
console.log("[debug] ws closed", {
|
|
43
|
+
id: connectionId,
|
|
44
|
+
label,
|
|
45
|
+
code,
|
|
46
|
+
reason: formatDebugPayload(reason, debugLogMaxBody),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function debugMiddleware(req, res, next) {
|
|
52
|
+
if (!debugApiWsLog || !req.path.startsWith("/api")) {
|
|
53
|
+
next();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const requestId = createDebugId();
|
|
57
|
+
const startedAt = Date.now();
|
|
58
|
+
|
|
59
|
+
console.log("[debug] api request", {
|
|
60
|
+
id: requestId,
|
|
61
|
+
method: req.method,
|
|
62
|
+
url: req.originalUrl,
|
|
63
|
+
query: req.query,
|
|
64
|
+
body: formatDebugPayload(req.body, debugLogMaxBody),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let responseBody;
|
|
68
|
+
const originalSend = res.send.bind(res);
|
|
69
|
+
res.send = (body) => {
|
|
70
|
+
responseBody = body;
|
|
71
|
+
return originalSend(body);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
res.on("finish", () => {
|
|
75
|
+
const durationMs = Date.now() - startedAt;
|
|
76
|
+
const formattedBody =
|
|
77
|
+
responseBody === undefined && res.statusCode !== 204
|
|
78
|
+
? "<streamed or empty>"
|
|
79
|
+
: formatDebugPayload(responseBody, debugLogMaxBody);
|
|
80
|
+
console.log("[debug] api response", {
|
|
81
|
+
id: requestId,
|
|
82
|
+
status: res.statusCode,
|
|
83
|
+
durationMs,
|
|
84
|
+
body: formattedBody,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
next();
|
|
89
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function errorTypesMiddleware(req, res, next) {
|
|
2
|
+
const originalJson = res.json.bind(res);
|
|
3
|
+
res.json = (body) => {
|
|
4
|
+
if (
|
|
5
|
+
res.statusCode >= 400 &&
|
|
6
|
+
body &&
|
|
7
|
+
typeof body === "object" &&
|
|
8
|
+
!Array.isArray(body) &&
|
|
9
|
+
!body.error_type
|
|
10
|
+
) {
|
|
11
|
+
const message = String(body.error || body.message || "");
|
|
12
|
+
const normalized = message.toLowerCase();
|
|
13
|
+
let errorType = `HTTP_${res.statusCode}`;
|
|
14
|
+
if (res.statusCode === 401) {
|
|
15
|
+
if (normalized.includes("missing workspace token")) {
|
|
16
|
+
errorType = "WORKSPACE_TOKEN_MISSING";
|
|
17
|
+
} else if (normalized.includes("invalid workspace token")) {
|
|
18
|
+
errorType = "WORKSPACE_TOKEN_INVALID";
|
|
19
|
+
} else {
|
|
20
|
+
errorType = "UNAUTHORIZED";
|
|
21
|
+
}
|
|
22
|
+
} else if (res.statusCode === 403) {
|
|
23
|
+
if (normalized.includes("invalid workspace credentials")) {
|
|
24
|
+
errorType = "WORKSPACE_CREDENTIALS_INVALID";
|
|
25
|
+
} else if (normalized.includes("provider not enabled")) {
|
|
26
|
+
errorType = "PROVIDER_NOT_ENABLED";
|
|
27
|
+
} else {
|
|
28
|
+
errorType = "FORBIDDEN";
|
|
29
|
+
}
|
|
30
|
+
} else if (res.statusCode === 404) {
|
|
31
|
+
if (normalized.includes("session not found")) {
|
|
32
|
+
errorType = "SESSION_NOT_FOUND";
|
|
33
|
+
} else if (normalized.includes("worktree not found")) {
|
|
34
|
+
errorType = "WORKTREE_NOT_FOUND";
|
|
35
|
+
} else {
|
|
36
|
+
errorType = "NOT_FOUND";
|
|
37
|
+
}
|
|
38
|
+
} else if (res.statusCode === 400) {
|
|
39
|
+
if (normalized.includes("invalid workspaceid")) {
|
|
40
|
+
errorType = "WORKSPACE_ID_INVALID";
|
|
41
|
+
} else if (normalized.includes("repourl is required")) {
|
|
42
|
+
errorType = "REPO_URL_REQUIRED";
|
|
43
|
+
} else if (normalized.includes("invalid provider")) {
|
|
44
|
+
errorType = "PROVIDER_INVALID";
|
|
45
|
+
} else if (normalized.includes("invalid session")) {
|
|
46
|
+
errorType = "SESSION_INVALID";
|
|
47
|
+
} else if (normalized.includes("branch is required")) {
|
|
48
|
+
errorType = "BRANCH_REQUIRED";
|
|
49
|
+
} else {
|
|
50
|
+
errorType = "BAD_REQUEST";
|
|
51
|
+
}
|
|
52
|
+
} else if (res.statusCode >= 500) {
|
|
53
|
+
errorType = "INTERNAL_ERROR";
|
|
54
|
+
}
|
|
55
|
+
body = { ...body, error_type: errorType };
|
|
56
|
+
}
|
|
57
|
+
return originalJson(body);
|
|
58
|
+
};
|
|
59
|
+
next();
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
const isLoggingEnabled = () => {
|
|
6
|
+
const value = process.env.ACTIVATE_PROVIDER_LOG;
|
|
7
|
+
if (!value) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return !["0", "false", "off"].includes(String(value).toLowerCase());
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const createProviderLogger = ({ provider, sessionId, worktreeId }) => {
|
|
14
|
+
if (!isLoggingEnabled()) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!sessionId) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const safeWorktreeId = worktreeId || "main";
|
|
21
|
+
const baseDir =
|
|
22
|
+
process.env.PROVIDER_LOG_DIRECTORY || path.join(os.homedir(), "logs");
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const filePath = path.join(
|
|
26
|
+
baseDir,
|
|
27
|
+
`${provider}_${sessionId}_${safeWorktreeId}.log`
|
|
28
|
+
);
|
|
29
|
+
const stream = fs.createWriteStream(filePath, { flags: "a" });
|
|
30
|
+
stream.on("error", (error) => {
|
|
31
|
+
console.warn(
|
|
32
|
+
`[provider-log] Failed to write ${provider} log: ${error?.message || error}`
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
filePath,
|
|
37
|
+
writeLine: (prefix, line) => {
|
|
38
|
+
const text = line == null ? "" : String(line);
|
|
39
|
+
if (!text) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const lines = text.split(/\r?\n/);
|
|
43
|
+
for (const entry of lines) {
|
|
44
|
+
if (entry === "") {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
stream.write(`${prefix}::${entry}\n`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
close: () => {
|
|
51
|
+
stream.end();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[provider-log] Failed to initialize ${provider} log: ${error?.message || error}`
|
|
57
|
+
);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { Router } from "express";
|
|
4
|
+
import { runAsCommand } from "../runAs.js";
|
|
5
|
+
import { sanitizeFilename } from "../helpers.js";
|
|
6
|
+
import {
|
|
7
|
+
getSession,
|
|
8
|
+
touchSession,
|
|
9
|
+
runSessionCommandOutput,
|
|
10
|
+
upload,
|
|
11
|
+
} from "../services/session.js";
|
|
12
|
+
|
|
13
|
+
export default function fileRoutes() {
|
|
14
|
+
const router = Router();
|
|
15
|
+
|
|
16
|
+
router.get("/sessions/:sessionId/attachments/file", async (req, res) => {
|
|
17
|
+
const sessionId = req.params.sessionId;
|
|
18
|
+
const session = await getSession(sessionId, req.workspaceId);
|
|
19
|
+
if (!session) {
|
|
20
|
+
res.status(400).json({ error: "Invalid session." });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await touchSession(session);
|
|
24
|
+
const rawPath = req.query.path;
|
|
25
|
+
const rawName = req.query.name;
|
|
26
|
+
if (!rawPath && !rawName) {
|
|
27
|
+
res.status(400).json({ error: "Attachment path is required." });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const candidatePath = rawPath
|
|
31
|
+
? path.resolve(rawPath)
|
|
32
|
+
: path.resolve(session.attachmentsDir, sanitizeFilename(rawName));
|
|
33
|
+
const relative = path.relative(session.attachmentsDir, candidatePath);
|
|
34
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
35
|
+
res.status(400).json({ error: "Invalid attachment path." });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const data = await runSessionCommandOutput(session, "/bin/cat", [candidatePath], {
|
|
40
|
+
binary: true,
|
|
41
|
+
});
|
|
42
|
+
if (rawName) {
|
|
43
|
+
res.setHeader("Content-Disposition", `attachment; filename="${sanitizeFilename(rawName)}"`);
|
|
44
|
+
}
|
|
45
|
+
res.send(data);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
res.status(404).json({ error: "Attachment not found." });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
router.get("/sessions/:sessionId/attachments", async (req, res) => {
|
|
52
|
+
const sessionId = req.params.sessionId;
|
|
53
|
+
const session = await getSession(sessionId, req.workspaceId);
|
|
54
|
+
if (!session) {
|
|
55
|
+
res.status(400).json({ error: "Invalid session." });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await touchSession(session);
|
|
59
|
+
try {
|
|
60
|
+
const output = await runSessionCommandOutput(
|
|
61
|
+
session,
|
|
62
|
+
"/usr/bin/find",
|
|
63
|
+
[session.attachmentsDir, "-maxdepth", "1", "-mindepth", "1", "-type", "f", "-printf", "%f\t%s\0"],
|
|
64
|
+
{ binary: true }
|
|
65
|
+
);
|
|
66
|
+
const files = output
|
|
67
|
+
.toString("utf8")
|
|
68
|
+
.split("\0")
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map((line) => {
|
|
71
|
+
const [name, sizeRaw] = line.split("\t");
|
|
72
|
+
return {
|
|
73
|
+
name,
|
|
74
|
+
path: path.join(session.attachmentsDir, name),
|
|
75
|
+
size: Number.parseInt(sizeRaw, 10),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
res.json({ files });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
res.status(500).json({ error: "Failed to list attachments." });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.post(
|
|
85
|
+
"/sessions/:sessionId/attachments/upload",
|
|
86
|
+
upload.array("files"),
|
|
87
|
+
async (req, res) => {
|
|
88
|
+
const sessionId = req.params.sessionId;
|
|
89
|
+
const session = await getSession(sessionId, req.workspaceId);
|
|
90
|
+
if (!session) {
|
|
91
|
+
res.status(400).json({ error: "Invalid session." });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await touchSession(session);
|
|
95
|
+
const uploaded = [];
|
|
96
|
+
for (const file of req.files || []) {
|
|
97
|
+
const targetPath = path.join(session.attachmentsDir, file.filename);
|
|
98
|
+
const inputStream = fs.createReadStream(file.path);
|
|
99
|
+
await runAsCommand(session.workspaceId, "/usr/bin/tee", [targetPath], {
|
|
100
|
+
input: inputStream,
|
|
101
|
+
});
|
|
102
|
+
await fs.promises.rm(file.path, { force: true });
|
|
103
|
+
uploaded.push({
|
|
104
|
+
name: file.filename,
|
|
105
|
+
path: targetPath,
|
|
106
|
+
size: file.size,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
res.json({ files: uploaded });
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return router;
|
|
114
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import {
|
|
3
|
+
getSession,
|
|
4
|
+
touchSession,
|
|
5
|
+
runSessionCommand,
|
|
6
|
+
runSessionCommandOutput,
|
|
7
|
+
getBranchInfo,
|
|
8
|
+
broadcastRepoDiff,
|
|
9
|
+
getRepoDiff,
|
|
10
|
+
isValidProvider,
|
|
11
|
+
modelCache,
|
|
12
|
+
modelCacheTtlMs,
|
|
13
|
+
} from "../services/session.js";
|
|
14
|
+
|
|
15
|
+
export default function gitRoutes(deps) {
|
|
16
|
+
const { getOrCreateClient } = deps;
|
|
17
|
+
|
|
18
|
+
const router = Router();
|
|
19
|
+
|
|
20
|
+
const readGitConfigValue = async (session, args) => {
|
|
21
|
+
try {
|
|
22
|
+
const output = await runSessionCommandOutput(session, "git", args);
|
|
23
|
+
return output.trim();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
router.get("/sessions/:sessionId/git-identity", async (req, res) => {
|
|
30
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
31
|
+
if (!session) {
|
|
32
|
+
res.status(404).json({ error: "Session not found." });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await touchSession(session);
|
|
36
|
+
try {
|
|
37
|
+
const [globalName, globalEmail, repoName, repoEmail] = await Promise.all([
|
|
38
|
+
readGitConfigValue(session, ["config", "--global", "--get", "user.name"]),
|
|
39
|
+
readGitConfigValue(session, ["config", "--global", "--get", "user.email"]),
|
|
40
|
+
readGitConfigValue(session, [
|
|
41
|
+
"-C",
|
|
42
|
+
session.repoDir,
|
|
43
|
+
"config",
|
|
44
|
+
"--get",
|
|
45
|
+
"user.name",
|
|
46
|
+
]),
|
|
47
|
+
readGitConfigValue(session, [
|
|
48
|
+
"-C",
|
|
49
|
+
session.repoDir,
|
|
50
|
+
"config",
|
|
51
|
+
"--get",
|
|
52
|
+
"user.email",
|
|
53
|
+
]),
|
|
54
|
+
]);
|
|
55
|
+
const effectiveName = repoName || globalName;
|
|
56
|
+
const effectiveEmail = repoEmail || globalEmail;
|
|
57
|
+
res.json({
|
|
58
|
+
global: { name: globalName || "", email: globalEmail || "" },
|
|
59
|
+
repo: { name: repoName || "", email: repoEmail || "" },
|
|
60
|
+
effective: { name: effectiveName || "", email: effectiveEmail || "" },
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
res.status(500).json({ error: "Failed to read git identity." });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
router.post("/sessions/:sessionId/git-identity", async (req, res) => {
|
|
68
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
69
|
+
if (!session) {
|
|
70
|
+
res.status(404).json({ error: "Session not found." });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await touchSession(session);
|
|
74
|
+
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
|
75
|
+
const email = typeof req.body?.email === "string" ? req.body.email.trim() : "";
|
|
76
|
+
if (!name || !email) {
|
|
77
|
+
res.status(400).json({ error: "name and email are required." });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await runSessionCommand(session, "git", [
|
|
82
|
+
"-C",
|
|
83
|
+
session.repoDir,
|
|
84
|
+
"config",
|
|
85
|
+
"user.name",
|
|
86
|
+
name,
|
|
87
|
+
]);
|
|
88
|
+
await runSessionCommand(session, "git", [
|
|
89
|
+
"-C",
|
|
90
|
+
session.repoDir,
|
|
91
|
+
"config",
|
|
92
|
+
"user.email",
|
|
93
|
+
email,
|
|
94
|
+
]);
|
|
95
|
+
res.json({ ok: true, repo: { name, email } });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
res.status(500).json({ error: "Failed to update git identity." });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.get("/sessions/:sessionId/diff", async (req, res) => {
|
|
102
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
103
|
+
if (!session) {
|
|
104
|
+
res.status(404).json({ error: "Session not found." });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await touchSession(session);
|
|
108
|
+
const repoDiff = await getRepoDiff(session);
|
|
109
|
+
res.json(repoDiff);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
router.get("/sessions/:sessionId/branches", async (req, res) => {
|
|
113
|
+
const sessionId = req.params.sessionId;
|
|
114
|
+
const session = await getSession(sessionId, req.workspaceId);
|
|
115
|
+
if (!session) {
|
|
116
|
+
res.status(400).json({ error: "Invalid session." });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await touchSession(session);
|
|
120
|
+
try {
|
|
121
|
+
const info = await getBranchInfo(session);
|
|
122
|
+
res.json(info);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error("Failed to list branches:", {
|
|
125
|
+
sessionId,
|
|
126
|
+
error: error?.message || error,
|
|
127
|
+
});
|
|
128
|
+
res.status(500).json({ error: "Failed to list branches." });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
router.get("/sessions/:sessionId/models", async (req, res) => {
|
|
133
|
+
const session = await getSession(req.params.sessionId, req.workspaceId);
|
|
134
|
+
const provider = req.query?.provider;
|
|
135
|
+
if (!session) {
|
|
136
|
+
res.status(404).json({ error: "Session not found." });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await touchSession(session);
|
|
140
|
+
if (!isValidProvider(provider)) {
|
|
141
|
+
res.status(400).json({ error: "Invalid provider. Must be 'codex' or 'claude'." });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
Array.isArray(session.providers) &&
|
|
146
|
+
session.providers.length &&
|
|
147
|
+
!session.providers.includes(provider)
|
|
148
|
+
) {
|
|
149
|
+
res.status(403).json({ error: "Provider not enabled for this session." });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const cached = modelCache.get(provider);
|
|
155
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
156
|
+
res.json({ models: cached.models, provider });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const client = await getOrCreateClient(session, provider);
|
|
160
|
+
if (!client.ready) {
|
|
161
|
+
await client.start();
|
|
162
|
+
}
|
|
163
|
+
let cursor = null;
|
|
164
|
+
const models = [];
|
|
165
|
+
do {
|
|
166
|
+
const result = await client.listModels(cursor, 200);
|
|
167
|
+
if (Array.isArray(result?.data)) {
|
|
168
|
+
models.push(...result.data);
|
|
169
|
+
}
|
|
170
|
+
cursor = result?.nextCursor ?? null;
|
|
171
|
+
} while (cursor);
|
|
172
|
+
modelCache.set(provider, {
|
|
173
|
+
models,
|
|
174
|
+
expiresAt: Date.now() + modelCacheTtlMs,
|
|
175
|
+
});
|
|
176
|
+
res.json({ models, provider });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
res.status(500).json({ error: error.message || "Failed to list models." });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return router;
|
|
183
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
|
|
3
|
+
export default function healthRoutes(deps) {
|
|
4
|
+
const { deploymentMode } = deps;
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
router.get("/health", async (req, res) => {
|
|
9
|
+
res.json({ ok: true, ready: true, deploymentMode });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return router;
|
|
13
|
+
}
|