codepiper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- package/scripts/postinstall-link-workspaces.mjs +58 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isBodyTooLarge,
|
|
3
|
+
isImageBodyTooLarge,
|
|
4
|
+
MAX_BODY_SIZE,
|
|
5
|
+
MAX_IMAGE_BODY_SIZE,
|
|
6
|
+
} from "../auth/authMiddleware";
|
|
7
|
+
|
|
8
|
+
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
9
|
+
const IMAGE_UPLOAD_PATH = /^\/(?:api\/)?sessions\/[a-zA-Z0-9-]+\/upload-image$/;
|
|
10
|
+
const AUDIO_TRANSCRIBE_PATH = /^\/(?:api\/)?sessions\/[a-zA-Z0-9-]+\/terminal\/transcribe$/;
|
|
11
|
+
|
|
12
|
+
class BodyTooLargeError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "BodyTooLargeError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isImageUploadPath(pathname: string): boolean {
|
|
20
|
+
return IMAGE_UPLOAD_PATH.test(pathname);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isAudioTranscribePath(pathname: string): boolean {
|
|
24
|
+
return AUDIO_TRANSCRIBE_PATH.test(pathname);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
28
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readBodyWithLimit(req: Request, maxBytes: number): Promise<Uint8Array | null> {
|
|
32
|
+
if (!req.body) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const reader = req.body.getReader();
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
let total = 0;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
while (true) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done) {
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
if (!value) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
total += value.byteLength;
|
|
51
|
+
if (total > maxBytes) {
|
|
52
|
+
try {
|
|
53
|
+
await reader.cancel();
|
|
54
|
+
} catch {
|
|
55
|
+
// best-effort cancellation
|
|
56
|
+
}
|
|
57
|
+
throw new BodyTooLargeError(`Request body exceeds ${maxBytes} bytes`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
chunks.push(value);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
reader.releaseLock();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const body = new Uint8Array(total);
|
|
67
|
+
let offset = 0;
|
|
68
|
+
for (const chunk of chunks) {
|
|
69
|
+
body.set(chunk, offset);
|
|
70
|
+
offset += chunk.byteLength;
|
|
71
|
+
}
|
|
72
|
+
return body;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Enforce request body limits:
|
|
77
|
+
* - image/audio upload routes: 10MB limit
|
|
78
|
+
* - all other write routes: 1MB limit, including chunked bodies without Content-Length.
|
|
79
|
+
*
|
|
80
|
+
* Returns either the original/rebuilt Request, or a 413 Response.
|
|
81
|
+
*/
|
|
82
|
+
export async function enforceRequestBodyLimit(
|
|
83
|
+
req: Request,
|
|
84
|
+
pathname: string
|
|
85
|
+
): Promise<Request | Response> {
|
|
86
|
+
if (!BODY_METHODS.has(req.method.toUpperCase())) {
|
|
87
|
+
return req;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isImageUploadPath(pathname) || isAudioTranscribePath(pathname)) {
|
|
91
|
+
if (isImageBodyTooLarge(req)) {
|
|
92
|
+
return Response.json({ error: "Upload too large (max 10MB)" }, { status: 413 });
|
|
93
|
+
}
|
|
94
|
+
if (!req.headers.has("content-length")) {
|
|
95
|
+
try {
|
|
96
|
+
const body = await readBodyWithLimit(req, MAX_IMAGE_BODY_SIZE);
|
|
97
|
+
if (!body) {
|
|
98
|
+
return req;
|
|
99
|
+
}
|
|
100
|
+
return new Request(req, { body: toArrayBuffer(body) });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof BodyTooLargeError) {
|
|
103
|
+
return Response.json({ error: "Upload too large (max 10MB)" }, { status: 413 });
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return req;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isBodyTooLarge(req)) {
|
|
112
|
+
return Response.json({ error: "Request too large" }, { status: 413 });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If Content-Length is present and already validated, no additional read is needed.
|
|
116
|
+
if (req.headers.has("content-length")) {
|
|
117
|
+
return req;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// No Content-Length (e.g. chunked): stream and enforce the same limit.
|
|
121
|
+
try {
|
|
122
|
+
const body = await readBodyWithLimit(req, MAX_BODY_SIZE);
|
|
123
|
+
if (!body) {
|
|
124
|
+
return req;
|
|
125
|
+
}
|
|
126
|
+
return new Request(req, { body: toArrayBuffer(body) });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof BodyTooLargeError) {
|
|
129
|
+
return Response.json({ error: "Request too large" }, { status: 413 });
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment Variable Set API route handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { RouteContext } from "./routes";
|
|
6
|
+
import { hasAnyDefinedField, jsonError, parseJsonBody } from "./routeUtils";
|
|
7
|
+
import { getErrorMessage, isSqliteUniqueConstraintError } from "./sqliteErrors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET /env-sets - List all env sets (with masked values)
|
|
11
|
+
*/
|
|
12
|
+
export async function handleListEnvSets(_req: Request, ctx: RouteContext): Promise<Response> {
|
|
13
|
+
const envSets = ctx.db.listEnvSets();
|
|
14
|
+
return Response.json({ envSets });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* POST /env-sets - Create a new env set
|
|
19
|
+
*/
|
|
20
|
+
export async function handleCreateEnvSet(req: Request, ctx: RouteContext): Promise<Response> {
|
|
21
|
+
const parsed = await parseJsonBody(req);
|
|
22
|
+
if (!parsed.ok) {
|
|
23
|
+
return parsed.response;
|
|
24
|
+
}
|
|
25
|
+
const body = parsed.body as any;
|
|
26
|
+
|
|
27
|
+
if (!body.name || typeof body.name !== "string") {
|
|
28
|
+
return jsonError(400, "Missing required field: name");
|
|
29
|
+
}
|
|
30
|
+
if (body.name.trim().length === 0) {
|
|
31
|
+
return jsonError(400, "Field 'name' must not be empty");
|
|
32
|
+
}
|
|
33
|
+
if (body.name.length > 100) {
|
|
34
|
+
return jsonError(400, "Name too long (max 100 chars)");
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
body.description !== undefined &&
|
|
38
|
+
body.description !== null &&
|
|
39
|
+
typeof body.description !== "string"
|
|
40
|
+
) {
|
|
41
|
+
return jsonError(400, "Field 'description' must be a string or null");
|
|
42
|
+
}
|
|
43
|
+
if (!body.vars || typeof body.vars !== "object" || Array.isArray(body.vars)) {
|
|
44
|
+
return jsonError(400, "Missing required field: vars (must be an object)");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Validate all keys and values are strings
|
|
48
|
+
for (const [key, value] of Object.entries(body.vars)) {
|
|
49
|
+
if (typeof key !== "string" || typeof value !== "string") {
|
|
50
|
+
return jsonError(400, `All env var keys and values must be strings. Invalid: ${key}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const id = crypto.randomUUID();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
ctx.db.createEnvSet({
|
|
58
|
+
id,
|
|
59
|
+
name: body.name,
|
|
60
|
+
description: body.description,
|
|
61
|
+
vars: body.vars,
|
|
62
|
+
});
|
|
63
|
+
const envSet = ctx.db.getEnvSet(id);
|
|
64
|
+
return Response.json({ envSet }, { status: 201 });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const msg = getErrorMessage(error);
|
|
67
|
+
if (isSqliteUniqueConstraintError(error)) {
|
|
68
|
+
return jsonError(409, `Env set name already exists: ${body.name}`);
|
|
69
|
+
}
|
|
70
|
+
return jsonError(500, msg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* GET /env-sets/:id - Get a specific env set (with masked values)
|
|
76
|
+
*/
|
|
77
|
+
export async function handleGetEnvSet(
|
|
78
|
+
_req: Request,
|
|
79
|
+
ctx: RouteContext,
|
|
80
|
+
envSetId: string
|
|
81
|
+
): Promise<Response> {
|
|
82
|
+
const envSet = ctx.db.getEnvSet(envSetId);
|
|
83
|
+
if (!envSet) {
|
|
84
|
+
return jsonError(404, `Env set not found: ${envSetId}`);
|
|
85
|
+
}
|
|
86
|
+
return Response.json({ envSet });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* PUT /env-sets/:id - Update an env set
|
|
91
|
+
*/
|
|
92
|
+
export async function handleUpdateEnvSet(
|
|
93
|
+
req: Request,
|
|
94
|
+
ctx: RouteContext,
|
|
95
|
+
envSetId: string
|
|
96
|
+
): Promise<Response> {
|
|
97
|
+
const parsed = await parseJsonBody(req);
|
|
98
|
+
if (!parsed.ok) {
|
|
99
|
+
return parsed.response;
|
|
100
|
+
}
|
|
101
|
+
const body = parsed.body as any;
|
|
102
|
+
|
|
103
|
+
const existing = ctx.db.getEnvSet(envSetId);
|
|
104
|
+
if (!existing) {
|
|
105
|
+
return jsonError(404, `Env set not found: ${envSetId}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (body.name !== undefined && typeof body.name !== "string") {
|
|
109
|
+
return jsonError(400, "name must be a string");
|
|
110
|
+
}
|
|
111
|
+
if (body.name !== undefined && body.name.trim().length === 0) {
|
|
112
|
+
return jsonError(400, "name must not be empty");
|
|
113
|
+
}
|
|
114
|
+
if (body.name !== undefined && body.name.length > 100) {
|
|
115
|
+
return jsonError(400, "Name too long (max 100 chars)");
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
body.description !== undefined &&
|
|
119
|
+
body.description !== null &&
|
|
120
|
+
typeof body.description !== "string"
|
|
121
|
+
) {
|
|
122
|
+
return jsonError(400, "description must be a string or null");
|
|
123
|
+
}
|
|
124
|
+
if (body.vars !== undefined) {
|
|
125
|
+
if (typeof body.vars !== "object" || Array.isArray(body.vars)) {
|
|
126
|
+
return jsonError(400, "vars must be an object");
|
|
127
|
+
}
|
|
128
|
+
for (const [key, value] of Object.entries(body.vars)) {
|
|
129
|
+
if (typeof key !== "string" || typeof value !== "string") {
|
|
130
|
+
return jsonError(400, `All env var keys and values must be strings. Invalid: ${key}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!hasAnyDefinedField(body, ["name", "description", "vars"])) {
|
|
135
|
+
return jsonError(422, "At least one field must be provided: name, description, or vars");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
ctx.db.updateEnvSet(envSetId, {
|
|
140
|
+
name: body.name,
|
|
141
|
+
description: body.description,
|
|
142
|
+
vars: body.vars,
|
|
143
|
+
});
|
|
144
|
+
const envSet = ctx.db.getEnvSet(envSetId);
|
|
145
|
+
return Response.json({ envSet });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const msg = getErrorMessage(error);
|
|
148
|
+
if (isSqliteUniqueConstraintError(error)) {
|
|
149
|
+
return jsonError(409, `Env set name already exists: ${body.name}`);
|
|
150
|
+
}
|
|
151
|
+
return jsonError(500, msg);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* DELETE /env-sets/:id - Delete an env set
|
|
157
|
+
*/
|
|
158
|
+
export async function handleDeleteEnvSet(
|
|
159
|
+
_req: Request,
|
|
160
|
+
ctx: RouteContext,
|
|
161
|
+
envSetId: string
|
|
162
|
+
): Promise<Response> {
|
|
163
|
+
const existing = ctx.db.getEnvSet(envSetId);
|
|
164
|
+
if (!existing) {
|
|
165
|
+
return jsonError(404, `Env set not found: ${envSetId}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ctx.db.deleteEnvSet(envSetId);
|
|
169
|
+
return Response.json({ success: true });
|
|
170
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git API route handlers
|
|
3
|
+
*
|
|
4
|
+
* Provides git status, log, diff, file content, and staging operations
|
|
5
|
+
* scoped to a session's working directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { GitUtils, validateFilePath, validateGitRef } from "../git/gitUtils";
|
|
9
|
+
import type { RouteContext } from "./routes";
|
|
10
|
+
|
|
11
|
+
const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
12
|
+
png: "image/png",
|
|
13
|
+
jpg: "image/jpeg",
|
|
14
|
+
jpeg: "image/jpeg",
|
|
15
|
+
gif: "image/gif",
|
|
16
|
+
webp: "image/webp",
|
|
17
|
+
ico: "image/x-icon",
|
|
18
|
+
bmp: "image/bmp",
|
|
19
|
+
avif: "image/avif",
|
|
20
|
+
// SVG intentionally excluded — serving as image/svg+xml enables XSS via inline scripts
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
24
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getSessionCwd(
|
|
28
|
+
ctx: RouteContext,
|
|
29
|
+
sessionId: string
|
|
30
|
+
): Promise<{ cwd: string } | Response> {
|
|
31
|
+
const session = ctx.sessionManager.getSession(sessionId) ?? ctx.db.getSession(sessionId);
|
|
32
|
+
if (!session) {
|
|
33
|
+
return Response.json({ error: `Session not found: ${sessionId}` }, { status: 404 });
|
|
34
|
+
}
|
|
35
|
+
return { cwd: session.cwd };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function requireGitRepo(cwd: string): Promise<Response | null> {
|
|
39
|
+
const isRepo = await GitUtils.isGitRepo(cwd);
|
|
40
|
+
if (!isRepo) {
|
|
41
|
+
return Response.json({ error: "Session directory is not a git repository" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shared setup for git file endpoints: resolves session cwd, checks git repo,
|
|
48
|
+
* and extracts/validates ref + path query params.
|
|
49
|
+
*/
|
|
50
|
+
async function resolveGitFileParams(
|
|
51
|
+
req: Request,
|
|
52
|
+
ctx: RouteContext,
|
|
53
|
+
sessionId: string
|
|
54
|
+
): Promise<{ cwd: string; ref: string; filePath: string } | Response> {
|
|
55
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
56
|
+
if (result instanceof Response) return result;
|
|
57
|
+
|
|
58
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
59
|
+
if (repoCheck) return repoCheck;
|
|
60
|
+
|
|
61
|
+
const url = new URL(req.url);
|
|
62
|
+
const ref = url.searchParams.get("ref");
|
|
63
|
+
const filePath = url.searchParams.get("path");
|
|
64
|
+
|
|
65
|
+
if (!(ref && filePath)) {
|
|
66
|
+
return Response.json({ error: "Missing required query params: ref and path" }, { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
validateGitRef(ref);
|
|
71
|
+
validateFilePath(filePath);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const msg = error instanceof Error ? error.message : "Invalid input";
|
|
74
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { cwd: result.cwd, ref, filePath };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* GET /sessions/:id/git/status
|
|
82
|
+
*/
|
|
83
|
+
export async function handleGitStatus(
|
|
84
|
+
_req: Request,
|
|
85
|
+
ctx: RouteContext,
|
|
86
|
+
sessionId: string
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
89
|
+
if (result instanceof Response) return result;
|
|
90
|
+
|
|
91
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
92
|
+
if (repoCheck) return repoCheck;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const [status, branch] = await Promise.all([
|
|
96
|
+
GitUtils.getStatus(result.cwd),
|
|
97
|
+
GitUtils.getCurrentBranch(result.cwd),
|
|
98
|
+
]);
|
|
99
|
+
return Response.json({ status, branch });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
102
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* GET /sessions/:id/git/branches
|
|
108
|
+
*/
|
|
109
|
+
export async function handleGitBranches(
|
|
110
|
+
_req: Request,
|
|
111
|
+
ctx: RouteContext,
|
|
112
|
+
sessionId: string
|
|
113
|
+
): Promise<Response> {
|
|
114
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
115
|
+
if (result instanceof Response) return result;
|
|
116
|
+
|
|
117
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
118
|
+
if (repoCheck) return repoCheck;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const [branches, currentBranch] = await Promise.all([
|
|
122
|
+
GitUtils.listBranches(result.cwd),
|
|
123
|
+
GitUtils.getCurrentBranch(result.cwd),
|
|
124
|
+
]);
|
|
125
|
+
return Response.json({ branches, currentBranch });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
128
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* GET /sessions/:id/git/log?limit=50
|
|
134
|
+
*/
|
|
135
|
+
export async function handleGitLog(
|
|
136
|
+
req: Request,
|
|
137
|
+
ctx: RouteContext,
|
|
138
|
+
sessionId: string
|
|
139
|
+
): Promise<Response> {
|
|
140
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
141
|
+
if (result instanceof Response) return result;
|
|
142
|
+
|
|
143
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
144
|
+
if (repoCheck) return repoCheck;
|
|
145
|
+
|
|
146
|
+
const url = new URL(req.url);
|
|
147
|
+
const limit = Number.parseInt(url.searchParams.get("limit") || "50", 10);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const log = await GitUtils.getLog(result.cwd, { limit });
|
|
151
|
+
return Response.json({ log });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
154
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* GET /sessions/:id/git/diff?staged=true&commit=abc&commitA=x&commitB=y&path=file.ts
|
|
160
|
+
*/
|
|
161
|
+
export async function handleGitDiff(
|
|
162
|
+
req: Request,
|
|
163
|
+
ctx: RouteContext,
|
|
164
|
+
sessionId: string
|
|
165
|
+
): Promise<Response> {
|
|
166
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
167
|
+
if (result instanceof Response) return result;
|
|
168
|
+
|
|
169
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
170
|
+
if (repoCheck) return repoCheck;
|
|
171
|
+
|
|
172
|
+
const url = new URL(req.url);
|
|
173
|
+
const staged = url.searchParams.get("staged") === "true";
|
|
174
|
+
const commit = url.searchParams.get("commit");
|
|
175
|
+
const commitA = url.searchParams.get("commitA");
|
|
176
|
+
const commitB = url.searchParams.get("commitB");
|
|
177
|
+
const filePath = url.searchParams.get("path") || undefined;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
// Validate refs and paths
|
|
181
|
+
if (commit) validateGitRef(commit);
|
|
182
|
+
if (commitA) validateGitRef(commitA);
|
|
183
|
+
if (commitB) validateGitRef(commitB);
|
|
184
|
+
if (filePath) validateFilePath(filePath);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const msg = error instanceof Error ? error.message : "Invalid input";
|
|
187
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
let diff: string;
|
|
192
|
+
|
|
193
|
+
if (commit) {
|
|
194
|
+
diff = await GitUtils.getCommitDiff(result.cwd, commit, filePath);
|
|
195
|
+
} else {
|
|
196
|
+
diff = await GitUtils.getDiff(result.cwd, {
|
|
197
|
+
staged,
|
|
198
|
+
commitA: commitA || undefined,
|
|
199
|
+
commitB: commitB || undefined,
|
|
200
|
+
path: filePath,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return Response.json({ diff });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
207
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* GET /sessions/:id/git/file?ref=HEAD&path=src/foo.ts
|
|
213
|
+
* Use ref=WORKING_TREE to read the file from the on-disk working tree.
|
|
214
|
+
*/
|
|
215
|
+
export async function handleGitFile(
|
|
216
|
+
req: Request,
|
|
217
|
+
ctx: RouteContext,
|
|
218
|
+
sessionId: string
|
|
219
|
+
): Promise<Response> {
|
|
220
|
+
const params = await resolveGitFileParams(req, ctx, sessionId);
|
|
221
|
+
if (params instanceof Response) return params;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const content =
|
|
225
|
+
params.ref === "WORKING_TREE"
|
|
226
|
+
? await GitUtils.getFileFromWorkingTree(params.cwd, params.filePath)
|
|
227
|
+
: await GitUtils.getFileAtRef(params.cwd, params.ref, params.filePath);
|
|
228
|
+
return Response.json({ content });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
231
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* GET /sessions/:id/git/diff-stat?ref=abc123[&base=main]
|
|
237
|
+
*/
|
|
238
|
+
export async function handleGitDiffStat(
|
|
239
|
+
req: Request,
|
|
240
|
+
ctx: RouteContext,
|
|
241
|
+
sessionId: string
|
|
242
|
+
): Promise<Response> {
|
|
243
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
244
|
+
if (result instanceof Response) return result;
|
|
245
|
+
|
|
246
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
247
|
+
if (repoCheck) return repoCheck;
|
|
248
|
+
|
|
249
|
+
const url = new URL(req.url);
|
|
250
|
+
const ref = url.searchParams.get("ref");
|
|
251
|
+
const base = url.searchParams.get("base");
|
|
252
|
+
|
|
253
|
+
// Branch comparison mode: base...ref
|
|
254
|
+
if (base) {
|
|
255
|
+
try {
|
|
256
|
+
validateGitRef(base);
|
|
257
|
+
if (ref) validateGitRef(ref);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const msg = error instanceof Error ? error.message : "Invalid input";
|
|
260
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const files = await GitUtils.getDiffStatBetweenRefs(result.cwd, base, ref || "HEAD");
|
|
264
|
+
return Response.json({ files });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
267
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Single-ref mode (commit vs parent)
|
|
272
|
+
if (!ref) {
|
|
273
|
+
return Response.json({ error: "Missing required query param: ref" }, { status: 400 });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
validateGitRef(ref);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const msg = error instanceof Error ? error.message : "Invalid input";
|
|
280
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const files = await GitUtils.getDiffStat(result.cwd, ref);
|
|
285
|
+
return Response.json({ files });
|
|
286
|
+
} catch (error) {
|
|
287
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
288
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* GET /sessions/:id/git/file-raw?ref=HEAD&path=src/image.png
|
|
294
|
+
* Returns raw file bytes with appropriate Content-Type.
|
|
295
|
+
*/
|
|
296
|
+
export async function handleGitFileRaw(
|
|
297
|
+
req: Request,
|
|
298
|
+
ctx: RouteContext,
|
|
299
|
+
sessionId: string
|
|
300
|
+
): Promise<Response> {
|
|
301
|
+
const params = await resolveGitFileParams(req, ctx, sessionId);
|
|
302
|
+
if (params instanceof Response) return params;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const data = await GitUtils.getFileAtRefRaw(params.cwd, params.ref, params.filePath);
|
|
306
|
+
const ext = params.filePath.split(".").pop()?.toLowerCase() || "";
|
|
307
|
+
const contentType = IMAGE_MIME_TYPES[ext] || "application/octet-stream";
|
|
308
|
+
return new Response(toArrayBuffer(data), {
|
|
309
|
+
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
313
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* POST /sessions/:id/git/stage — body: { paths: string[] }
|
|
319
|
+
*/
|
|
320
|
+
export async function handleGitStage(
|
|
321
|
+
req: Request,
|
|
322
|
+
ctx: RouteContext,
|
|
323
|
+
sessionId: string
|
|
324
|
+
): Promise<Response> {
|
|
325
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
326
|
+
if (result instanceof Response) return result;
|
|
327
|
+
|
|
328
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
329
|
+
if (repoCheck) return repoCheck;
|
|
330
|
+
|
|
331
|
+
let body: any;
|
|
332
|
+
try {
|
|
333
|
+
body = await req.json();
|
|
334
|
+
} catch {
|
|
335
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!Array.isArray(body.paths) || body.paths.length === 0) {
|
|
339
|
+
return Response.json(
|
|
340
|
+
{ error: "Missing required field: paths (non-empty array)" },
|
|
341
|
+
{ status: 400 }
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
for (const p of body.paths) {
|
|
347
|
+
if (typeof p !== "string") throw new Error("Each path must be a string");
|
|
348
|
+
validateFilePath(p);
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const msg = error instanceof Error ? error.message : "Invalid input";
|
|
352
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await GitUtils.stageFiles(result.cwd, body.paths);
|
|
357
|
+
return Response.json({ success: true });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
360
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* POST /sessions/:id/git/unstage — body: { paths: string[] }
|
|
366
|
+
*/
|
|
367
|
+
export async function handleGitUnstage(
|
|
368
|
+
req: Request,
|
|
369
|
+
ctx: RouteContext,
|
|
370
|
+
sessionId: string
|
|
371
|
+
): Promise<Response> {
|
|
372
|
+
const result = await getSessionCwd(ctx, sessionId);
|
|
373
|
+
if (result instanceof Response) return result;
|
|
374
|
+
|
|
375
|
+
const repoCheck = await requireGitRepo(result.cwd);
|
|
376
|
+
if (repoCheck) return repoCheck;
|
|
377
|
+
|
|
378
|
+
let body: any;
|
|
379
|
+
try {
|
|
380
|
+
body = await req.json();
|
|
381
|
+
} catch {
|
|
382
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!Array.isArray(body.paths) || body.paths.length === 0) {
|
|
386
|
+
return Response.json(
|
|
387
|
+
{ error: "Missing required field: paths (non-empty array)" },
|
|
388
|
+
{ status: 400 }
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
for (const p of body.paths) {
|
|
394
|
+
if (typeof p !== "string") throw new Error("Each path must be a string");
|
|
395
|
+
validateFilePath(p);
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const msg = error instanceof Error ? error.message : "Invalid input";
|
|
399
|
+
return Response.json({ error: msg }, { status: 400 });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
await GitUtils.unstageFiles(result.cwd, body.paths);
|
|
404
|
+
return Response.json({ success: true });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
407
|
+
return Response.json({ error: msg }, { status: 500 });
|
|
408
|
+
}
|
|
409
|
+
}
|