@tstax/coding-tab 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/README.md +119 -0
- package/dist/browser.js +60 -0
- package/dist/browser.js.map +1 -0
- package/dist/server.cjs +709 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +111 -0
- package/dist/server.d.ts +111 -0
- package/dist/server.js +668 -0
- package/dist/server.js.map +1 -0
- package/dist/style.css +244 -0
- package/package.json +73 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import express from "express";
|
|
3
|
+
|
|
4
|
+
// src/server/authRoutes.ts
|
|
5
|
+
import { Router } from "express";
|
|
6
|
+
import { randomBytes } from "crypto";
|
|
7
|
+
|
|
8
|
+
// src/server/authMiddleware.ts
|
|
9
|
+
import { getIronSession } from "iron-session";
|
|
10
|
+
|
|
11
|
+
// src/server/session.ts
|
|
12
|
+
var SESSION_COOKIE_NAME = "coding_tab_session";
|
|
13
|
+
function buildSessionOptions(password, secure) {
|
|
14
|
+
return {
|
|
15
|
+
password,
|
|
16
|
+
cookieName: SESSION_COOKIE_NAME,
|
|
17
|
+
cookieOptions: {
|
|
18
|
+
httpOnly: true,
|
|
19
|
+
secure,
|
|
20
|
+
sameSite: "lax",
|
|
21
|
+
path: "/",
|
|
22
|
+
maxAge: 60 * 60 * 24 * 30
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/server/authMiddleware.ts
|
|
28
|
+
function getSession(req, res, opts) {
|
|
29
|
+
return getIronSession(req, res, buildSessionOptions(opts.sessionPassword, opts.secure));
|
|
30
|
+
}
|
|
31
|
+
function makeRequireAuth(opts) {
|
|
32
|
+
const allowed = new Set(opts.allowedLogins.map((l) => l.toLowerCase()));
|
|
33
|
+
return async function requireAuth(req, res, next) {
|
|
34
|
+
try {
|
|
35
|
+
const session = await getSession(req, res, opts);
|
|
36
|
+
if (!session.githubLogin || !session.accessToken) {
|
|
37
|
+
res.status(401).json({ error: "unauthenticated" });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (allowed.size > 0 && !allowed.has(session.githubLogin.toLowerCase())) {
|
|
41
|
+
res.status(403).json({ error: "not_authorized" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
req.user = {
|
|
45
|
+
githubLogin: session.githubLogin,
|
|
46
|
+
accessToken: session.accessToken,
|
|
47
|
+
avatarUrl: session.avatarUrl
|
|
48
|
+
};
|
|
49
|
+
next();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[${SESSION_COOKIE_NAME}] requireAuth failed`, err);
|
|
52
|
+
res.status(500).json({ error: "session_error" });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/server/authRoutes.ts
|
|
58
|
+
var OAUTH_STATE_TTL_MS = 10 * 60 * 1e3;
|
|
59
|
+
function makeAuthRouter(opts) {
|
|
60
|
+
const router = Router();
|
|
61
|
+
const allowed = new Set(opts.oauth.allowedLogins.map((l) => l.toLowerCase()));
|
|
62
|
+
const scopes = opts.oauth.scopes ?? ["repo", "read:user"];
|
|
63
|
+
router.get("/auth/login", async (req, res) => {
|
|
64
|
+
const session = await getSession(req, res, opts);
|
|
65
|
+
const state = randomBytes(24).toString("hex");
|
|
66
|
+
session.oauthState = state;
|
|
67
|
+
session.oauthStateCreatedAt = Date.now();
|
|
68
|
+
await session.save();
|
|
69
|
+
const url = new URL("https://github.com/login/oauth/authorize");
|
|
70
|
+
url.searchParams.set("client_id", opts.oauth.clientId);
|
|
71
|
+
url.searchParams.set("redirect_uri", opts.oauth.callbackUrl);
|
|
72
|
+
url.searchParams.set("scope", scopes.join(" "));
|
|
73
|
+
url.searchParams.set("state", state);
|
|
74
|
+
url.searchParams.set("allow_signup", "false");
|
|
75
|
+
res.redirect(302, url.toString());
|
|
76
|
+
});
|
|
77
|
+
router.get("/auth/callback", async (req, res) => {
|
|
78
|
+
const session = await getSession(req, res, opts);
|
|
79
|
+
const code = typeof req.query.code === "string" ? req.query.code : null;
|
|
80
|
+
const state = typeof req.query.state === "string" ? req.query.state : null;
|
|
81
|
+
const expectedState = session.oauthState;
|
|
82
|
+
const stateAge = session.oauthStateCreatedAt ? Date.now() - session.oauthStateCreatedAt : Number.POSITIVE_INFINITY;
|
|
83
|
+
session.oauthState = void 0;
|
|
84
|
+
session.oauthStateCreatedAt = void 0;
|
|
85
|
+
if (!code || !state || !expectedState || state !== expectedState || stateAge > OAUTH_STATE_TTL_MS) {
|
|
86
|
+
await session.save();
|
|
87
|
+
res.status(400).send("OAuth state mismatch or expired. Try signing in again.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const tokenResp = await fetch("https://github.com/login/oauth/access_token", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
Accept: "application/json",
|
|
95
|
+
"Content-Type": "application/json"
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
client_id: opts.oauth.clientId,
|
|
99
|
+
client_secret: opts.oauth.clientSecret,
|
|
100
|
+
code,
|
|
101
|
+
redirect_uri: opts.oauth.callbackUrl
|
|
102
|
+
})
|
|
103
|
+
});
|
|
104
|
+
const tokenJson = await tokenResp.json();
|
|
105
|
+
if (!tokenJson.access_token) {
|
|
106
|
+
await session.save();
|
|
107
|
+
res.status(401).send(`GitHub token exchange failed: ${tokenJson.error_description ?? tokenJson.error ?? "unknown"}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const userResp = await fetch("https://api.github.com/user", {
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${tokenJson.access_token}`,
|
|
113
|
+
Accept: "application/vnd.github+json",
|
|
114
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (!userResp.ok) {
|
|
118
|
+
await session.save();
|
|
119
|
+
res.status(401).send(`GitHub user lookup failed: ${userResp.status}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const user = await userResp.json();
|
|
123
|
+
if (allowed.size > 0 && !allowed.has(user.login.toLowerCase())) {
|
|
124
|
+
await session.save();
|
|
125
|
+
res.status(403).send(`@${user.login} is not on the allowlist for this app.`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
session.githubLogin = user.login;
|
|
129
|
+
session.avatarUrl = user.avatar_url;
|
|
130
|
+
session.accessToken = tokenJson.access_token;
|
|
131
|
+
await session.save();
|
|
132
|
+
res.redirect(302, opts.basePath + "/");
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error("[coding-tab] OAuth callback failed", err);
|
|
135
|
+
res.status(500).send("OAuth callback failed. Check server logs.");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
router.post("/auth/logout", async (req, res) => {
|
|
139
|
+
const session = await getSession(req, res, opts);
|
|
140
|
+
session.destroy();
|
|
141
|
+
res.json({ ok: true });
|
|
142
|
+
});
|
|
143
|
+
router.get("/auth/me", async (req, res) => {
|
|
144
|
+
const session = await getSession(req, res, opts);
|
|
145
|
+
if (!session.githubLogin || !session.accessToken) {
|
|
146
|
+
res.status(401).json({ error: "unauthenticated" });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (allowed.size > 0 && !allowed.has(session.githubLogin.toLowerCase())) {
|
|
150
|
+
res.status(403).json({ error: "not_authorized" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
res.json({ githubLogin: session.githubLogin, avatarUrl: session.avatarUrl });
|
|
154
|
+
});
|
|
155
|
+
return router;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/server/agentRoutes.ts
|
|
159
|
+
import { Router as Router2 } from "express";
|
|
160
|
+
import { Agent, CursorAgentError } from "@cursor/sdk";
|
|
161
|
+
|
|
162
|
+
// src/server/models.ts
|
|
163
|
+
import { Cursor } from "@cursor/sdk";
|
|
164
|
+
var SONNET_PATTERN = /sonnet/i;
|
|
165
|
+
var OPUS_PATTERN = /opus/i;
|
|
166
|
+
var cache = null;
|
|
167
|
+
var CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
168
|
+
async function getModelList(apiKey) {
|
|
169
|
+
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) return cache.models;
|
|
170
|
+
const models = await Cursor.models.list({ apiKey });
|
|
171
|
+
cache = { fetchedAt: Date.now(), models };
|
|
172
|
+
return models;
|
|
173
|
+
}
|
|
174
|
+
function pickBest(models, pattern) {
|
|
175
|
+
const matches = models.filter((m) => pattern.test(m.id) || pattern.test(m.displayName) || (m.aliases ?? []).some((a) => pattern.test(a)));
|
|
176
|
+
if (matches.length === 0) return void 0;
|
|
177
|
+
matches.sort((a, b) => b.id.localeCompare(a.id));
|
|
178
|
+
return matches[0];
|
|
179
|
+
}
|
|
180
|
+
async function listAvailableModels(apiKey) {
|
|
181
|
+
const models = await getModelList(apiKey);
|
|
182
|
+
const out = [];
|
|
183
|
+
const sonnet = pickBest(models, SONNET_PATTERN);
|
|
184
|
+
if (sonnet) out.push({ choice: "sonnet", cursorModelId: sonnet.id, displayName: sonnet.displayName ?? sonnet.id });
|
|
185
|
+
const opus = pickBest(models, OPUS_PATTERN);
|
|
186
|
+
if (opus) out.push({ choice: "opus", cursorModelId: opus.id, displayName: opus.displayName ?? opus.id });
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
async function resolveModel(apiKey, choice) {
|
|
190
|
+
const models = await getModelList(apiKey);
|
|
191
|
+
const pattern = choice === "sonnet" ? SONNET_PATTERN : OPUS_PATTERN;
|
|
192
|
+
const picked = pickBest(models, pattern);
|
|
193
|
+
if (!picked) {
|
|
194
|
+
const available = models.map((m) => `${m.displayName} (${m.id})`).join(", ");
|
|
195
|
+
throw new Error(`No Cursor-routed model matches "${choice}". Available: ${available}`);
|
|
196
|
+
}
|
|
197
|
+
return { cursorModelId: picked.id, displayName: picked.displayName ?? picked.id };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/server/sessions.ts
|
|
201
|
+
import { randomUUID } from "crypto";
|
|
202
|
+
var SESSION_IDLE_TTL_MS = 30 * 60 * 1e3;
|
|
203
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
204
|
+
var sweeperStarted = false;
|
|
205
|
+
function startSweeper() {
|
|
206
|
+
if (sweeperStarted) return;
|
|
207
|
+
sweeperStarted = true;
|
|
208
|
+
setInterval(() => {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
for (const [id, s] of sessions) {
|
|
211
|
+
if (now - s.lastUsedAt > SESSION_IDLE_TTL_MS) {
|
|
212
|
+
disposeSession(id).catch((e) => console.error("[coding-tab] session sweep dispose failed", e));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}, 5 * 60 * 1e3).unref?.();
|
|
216
|
+
}
|
|
217
|
+
function registerSession(opts) {
|
|
218
|
+
startSweeper();
|
|
219
|
+
const id = randomUUID();
|
|
220
|
+
const session = {
|
|
221
|
+
id,
|
|
222
|
+
githubLogin: opts.githubLogin,
|
|
223
|
+
agent: opts.agent,
|
|
224
|
+
repoUrl: opts.repoUrl,
|
|
225
|
+
startingRef: opts.startingRef,
|
|
226
|
+
createdAt: Date.now(),
|
|
227
|
+
lastUsedAt: Date.now()
|
|
228
|
+
};
|
|
229
|
+
sessions.set(id, session);
|
|
230
|
+
return session;
|
|
231
|
+
}
|
|
232
|
+
function getSession2(id) {
|
|
233
|
+
const s = sessions.get(id);
|
|
234
|
+
if (s) s.lastUsedAt = Date.now();
|
|
235
|
+
return s;
|
|
236
|
+
}
|
|
237
|
+
async function disposeSession(id) {
|
|
238
|
+
const s = sessions.get(id);
|
|
239
|
+
if (!s) return;
|
|
240
|
+
sessions.delete(id);
|
|
241
|
+
try {
|
|
242
|
+
await s.agent[Symbol.asyncDispose]();
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error("[coding-tab] dispose failed", err);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/server/agentRoutes.ts
|
|
249
|
+
var PLAN_INSTRUCTION = `You are operating in PLAN MODE.
|
|
250
|
+
|
|
251
|
+
Produce a clear, concise markdown plan for the user's request. Do NOT modify any files. Do NOT call any file-editing or shell tools that mutate state. Read-only exploration tools (Read, Grep, Glob, semantic search) are encouraged.
|
|
252
|
+
|
|
253
|
+
Structure the plan with:
|
|
254
|
+
- Goal (1 line)
|
|
255
|
+
- Approach (3-7 bullet points)
|
|
256
|
+
- Files that will change (bulleted, with absolute or repo-relative paths)
|
|
257
|
+
- Risks / things to verify after implementation
|
|
258
|
+
|
|
259
|
+
When you are done, end your message with a single line: PLAN READY`;
|
|
260
|
+
var EXECUTE_INSTRUCTION = `You are now in AGENT MODE. Implement the plan you produced above. Make all required file changes, run any necessary commands, and prepare the changes for a pull request. When complete, summarize what changed in 3-5 bullets.`;
|
|
261
|
+
function sse(res, event) {
|
|
262
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
263
|
+
|
|
264
|
+
`);
|
|
265
|
+
}
|
|
266
|
+
function parsePrUrl(url) {
|
|
267
|
+
if (!url) return void 0;
|
|
268
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
269
|
+
if (!match) return void 0;
|
|
270
|
+
return { url, owner: match[1], repo: match[2], number: Number(match[3]) };
|
|
271
|
+
}
|
|
272
|
+
function modeInstructionPrefix(mode, prompt) {
|
|
273
|
+
return mode === "plan" ? `${PLAN_INSTRUCTION}
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
User request:
|
|
278
|
+
${prompt}` : prompt;
|
|
279
|
+
}
|
|
280
|
+
function setupSseHeaders(res) {
|
|
281
|
+
res.set({
|
|
282
|
+
"Content-Type": "text/event-stream",
|
|
283
|
+
"Cache-Control": "no-cache, no-transform",
|
|
284
|
+
Connection: "keep-alive",
|
|
285
|
+
"X-Accel-Buffering": "no"
|
|
286
|
+
});
|
|
287
|
+
res.flushHeaders?.();
|
|
288
|
+
}
|
|
289
|
+
async function streamRun(res, run) {
|
|
290
|
+
try {
|
|
291
|
+
for await (const message of run.stream()) {
|
|
292
|
+
if (res.writableEnded) break;
|
|
293
|
+
if (message.type === "assistant") {
|
|
294
|
+
for (const block of message.message.content) {
|
|
295
|
+
if (block.type === "text" && block.text) sse(res, { kind: "text", text: block.text });
|
|
296
|
+
else if (block.type === "tool_use") sse(res, { kind: "tool", name: block.name, status: "running", callId: block.id, args: block.input });
|
|
297
|
+
}
|
|
298
|
+
} else if (message.type === "thinking") {
|
|
299
|
+
sse(res, { kind: "thinking", text: message.text });
|
|
300
|
+
} else if (message.type === "tool_call") {
|
|
301
|
+
sse(res, { kind: "tool", name: message.name, status: message.status, callId: message.call_id, args: message.args, result: message.result });
|
|
302
|
+
} else if (message.type === "status") {
|
|
303
|
+
sse(res, { kind: "status", status: message.status, message: message.message });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const result = await run.wait();
|
|
307
|
+
const prUrl = result.git?.branches?.find((b) => b.prUrl)?.prUrl;
|
|
308
|
+
sse(res, {
|
|
309
|
+
kind: "result",
|
|
310
|
+
status: result.status,
|
|
311
|
+
pr: parsePrUrl(prUrl),
|
|
312
|
+
durationMs: result.durationMs
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
316
|
+
const retryable = err instanceof CursorAgentError ? Boolean(err.isRetryable) : false;
|
|
317
|
+
sse(res, { kind: "error", message, retryable });
|
|
318
|
+
} finally {
|
|
319
|
+
if (!res.writableEnded) res.end();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function makeAgentRouter(opts) {
|
|
323
|
+
const router = Router2();
|
|
324
|
+
router.get("/models", async (_req, res) => {
|
|
325
|
+
try {
|
|
326
|
+
const models = await listAvailableModels(opts.cursorApiKey);
|
|
327
|
+
res.json({ models });
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.error("[coding-tab] /models failed", err);
|
|
330
|
+
res.status(500).json({ error: err instanceof Error ? err.message : "models_failed" });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
router.post("/agent/start", async (req, res) => {
|
|
334
|
+
const user = req.user;
|
|
335
|
+
const { prompt, mode, model, repoUrl, startingRef } = req.body ?? {};
|
|
336
|
+
if (!prompt || mode !== "plan" && mode !== "agent" || model !== "sonnet" && model !== "opus") {
|
|
337
|
+
res.status(400).json({ error: "invalid_request" });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
setupSseHeaders(res);
|
|
341
|
+
let agent;
|
|
342
|
+
try {
|
|
343
|
+
const resolved = await resolveModel(opts.cursorApiKey, model);
|
|
344
|
+
agent = await Agent.create({
|
|
345
|
+
apiKey: opts.cursorApiKey,
|
|
346
|
+
model: { id: resolved.cursorModelId },
|
|
347
|
+
cloud: {
|
|
348
|
+
repos: [
|
|
349
|
+
{
|
|
350
|
+
url: repoUrl ?? opts.defaultRepo.url,
|
|
351
|
+
startingRef: startingRef ?? opts.defaultRepo.ref
|
|
352
|
+
}
|
|
353
|
+
],
|
|
354
|
+
autoCreatePR: mode === "agent",
|
|
355
|
+
skipReviewerRequest: opts.skipReviewerRequest ?? true,
|
|
356
|
+
envVars: { GITHUB_TOKEN: user.accessToken },
|
|
357
|
+
...opts.envName ? { env: { type: "cloud", name: opts.envName } } : {}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
const session = registerSession({
|
|
361
|
+
githubLogin: user.githubLogin,
|
|
362
|
+
agent,
|
|
363
|
+
repoUrl: repoUrl ?? opts.defaultRepo.url,
|
|
364
|
+
startingRef: startingRef ?? opts.defaultRepo.ref
|
|
365
|
+
});
|
|
366
|
+
const run = await agent.send(modeInstructionPrefix(mode, prompt));
|
|
367
|
+
sse(res, { kind: "ready", sessionId: session.id, agentId: agent.agentId, runId: run.id });
|
|
368
|
+
console.log(`[coding-tab] start agent=${agent.agentId} run=${run.id} session=${session.id} login=${user.githubLogin}`);
|
|
369
|
+
await streamRun(res, run);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
372
|
+
const retryable = err instanceof CursorAgentError ? Boolean(err.isRetryable) : false;
|
|
373
|
+
console.error("[coding-tab] /agent/start failed", err);
|
|
374
|
+
sse(res, { kind: "error", message, retryable });
|
|
375
|
+
if (!res.writableEnded) res.end();
|
|
376
|
+
if (agent) await agent[Symbol.asyncDispose]().catch(() => {
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
req.on("close", () => {
|
|
380
|
+
if (!res.writableEnded) res.end();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
router.post("/agent/send", async (req, res) => {
|
|
384
|
+
const { sessionId, prompt, mode } = req.body ?? {};
|
|
385
|
+
if (!sessionId || !prompt || mode !== "plan" && mode !== "agent") {
|
|
386
|
+
res.status(400).json({ error: "invalid_request" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const session = getSession2(sessionId);
|
|
390
|
+
if (!session) {
|
|
391
|
+
res.status(404).json({ error: "session_not_found" });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
setupSseHeaders(res);
|
|
395
|
+
try {
|
|
396
|
+
const run = await session.agent.send(modeInstructionPrefix(mode, prompt));
|
|
397
|
+
sse(res, { kind: "ready", sessionId: session.id, agentId: session.agent.agentId, runId: run.id });
|
|
398
|
+
console.log(`[coding-tab] send agent=${session.agent.agentId} run=${run.id} session=${session.id}`);
|
|
399
|
+
await streamRun(res, run);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
402
|
+
console.error("[coding-tab] /agent/send failed", err);
|
|
403
|
+
sse(res, { kind: "error", message });
|
|
404
|
+
if (!res.writableEnded) res.end();
|
|
405
|
+
}
|
|
406
|
+
req.on("close", () => {
|
|
407
|
+
if (!res.writableEnded) res.end();
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
router.post("/agent/execute", async (req, res) => {
|
|
411
|
+
const { sessionId } = req.body ?? {};
|
|
412
|
+
if (!sessionId) {
|
|
413
|
+
res.status(400).json({ error: "invalid_request" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const session = getSession2(sessionId);
|
|
417
|
+
if (!session) {
|
|
418
|
+
res.status(404).json({ error: "session_not_found" });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
setupSseHeaders(res);
|
|
422
|
+
try {
|
|
423
|
+
const run = await session.agent.send(EXECUTE_INSTRUCTION);
|
|
424
|
+
sse(res, { kind: "ready", sessionId: session.id, agentId: session.agent.agentId, runId: run.id });
|
|
425
|
+
console.log(`[coding-tab] execute agent=${session.agent.agentId} run=${run.id} session=${session.id}`);
|
|
426
|
+
await streamRun(res, run);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
429
|
+
console.error("[coding-tab] /agent/execute failed", err);
|
|
430
|
+
sse(res, { kind: "error", message });
|
|
431
|
+
if (!res.writableEnded) res.end();
|
|
432
|
+
}
|
|
433
|
+
req.on("close", () => {
|
|
434
|
+
if (!res.writableEnded) res.end();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
router.post("/agent/cancel", async (req, res) => {
|
|
438
|
+
const { sessionId, runId } = req.body ?? {};
|
|
439
|
+
if (!sessionId || !runId) {
|
|
440
|
+
res.status(400).json({ error: "invalid_request" });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const session = getSession2(sessionId);
|
|
444
|
+
if (!session) {
|
|
445
|
+
res.status(404).json({ error: "session_not_found" });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
await Agent.cancelRun(runId, { runtime: "cloud", agentId: session.agent.agentId, apiKey: opts.cursorApiKey });
|
|
450
|
+
res.json({ ok: true });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
453
|
+
console.error("[coding-tab] /agent/cancel failed", err);
|
|
454
|
+
res.status(500).json({ error: message });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
router.post("/agent/dispose", async (req, res) => {
|
|
458
|
+
const { sessionId } = req.body ?? {};
|
|
459
|
+
if (!sessionId) {
|
|
460
|
+
res.status(400).json({ error: "invalid_request" });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
await disposeSession(sessionId);
|
|
464
|
+
res.json({ ok: true });
|
|
465
|
+
});
|
|
466
|
+
return router;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/server/githubRoutes.ts
|
|
470
|
+
import { Router as Router3 } from "express";
|
|
471
|
+
import { Octokit } from "@octokit/rest";
|
|
472
|
+
function parsePrUrl2(prUrl) {
|
|
473
|
+
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
474
|
+
if (!match) return null;
|
|
475
|
+
return { owner: match[1], repo: match[2], number: Number(match[3]) };
|
|
476
|
+
}
|
|
477
|
+
function makeGitHubRouter() {
|
|
478
|
+
const router = Router3();
|
|
479
|
+
router.get("/pr/status", async (req, res) => {
|
|
480
|
+
const prUrl = typeof req.query.prUrl === "string" ? req.query.prUrl : null;
|
|
481
|
+
if (!prUrl) {
|
|
482
|
+
res.status(400).json({ error: "missing_prUrl" });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const parsed = parsePrUrl2(prUrl);
|
|
486
|
+
if (!parsed) {
|
|
487
|
+
res.status(400).json({ error: "invalid_prUrl" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const user = req.user;
|
|
491
|
+
const octokit = new Octokit({ auth: user.accessToken });
|
|
492
|
+
try {
|
|
493
|
+
const pr = await octokit.pulls.get({ owner: parsed.owner, repo: parsed.repo, pull_number: parsed.number });
|
|
494
|
+
const info = {
|
|
495
|
+
url: pr.data.html_url,
|
|
496
|
+
owner: parsed.owner,
|
|
497
|
+
repo: parsed.repo,
|
|
498
|
+
number: parsed.number,
|
|
499
|
+
branch: pr.data.head?.ref,
|
|
500
|
+
state: pr.data.merged ? "merged" : pr.data.state,
|
|
501
|
+
title: pr.data.title,
|
|
502
|
+
body: pr.data.body ?? void 0
|
|
503
|
+
};
|
|
504
|
+
res.json({ pr: info, mergeable: pr.data.mergeable, mergeable_state: pr.data.mergeable_state });
|
|
505
|
+
} catch (err) {
|
|
506
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
507
|
+
res.status(500).json({ error: message });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
router.post("/pr/merge", async (req, res) => {
|
|
511
|
+
const body = req.body ?? {};
|
|
512
|
+
if (!body.prUrl) {
|
|
513
|
+
res.status(400).json({ error: "missing_prUrl" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const parsed = parsePrUrl2(body.prUrl);
|
|
517
|
+
if (!parsed) {
|
|
518
|
+
res.status(400).json({ error: "invalid_prUrl" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const user = req.user;
|
|
522
|
+
const octokit = new Octokit({ auth: user.accessToken });
|
|
523
|
+
try {
|
|
524
|
+
const merge = await octokit.pulls.merge({
|
|
525
|
+
owner: parsed.owner,
|
|
526
|
+
repo: parsed.repo,
|
|
527
|
+
pull_number: parsed.number,
|
|
528
|
+
merge_method: body.mergeMethod ?? "squash"
|
|
529
|
+
});
|
|
530
|
+
res.json({ sha: merge.data.sha, merged: merge.data.merged });
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
533
|
+
console.error("[coding-tab] /pr/merge failed", err);
|
|
534
|
+
res.status(500).json({ error: message });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
return router;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/server/staticAssets.ts
|
|
541
|
+
import { Router as Router4 } from "express";
|
|
542
|
+
import { readFile } from "fs/promises";
|
|
543
|
+
import { resolve, dirname } from "path";
|
|
544
|
+
import { fileURLToPath } from "url";
|
|
545
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
546
|
+
var ASSET_CANDIDATES = [
|
|
547
|
+
resolve(here, "browser.js"),
|
|
548
|
+
resolve(here, "..", "dist", "browser.js"),
|
|
549
|
+
resolve(here, "..", "browser.js")
|
|
550
|
+
];
|
|
551
|
+
var STYLE_CANDIDATES = [
|
|
552
|
+
resolve(here, "style.css"),
|
|
553
|
+
resolve(here, "..", "dist", "style.css"),
|
|
554
|
+
resolve(here, "..", "style.css")
|
|
555
|
+
];
|
|
556
|
+
async function readFirst(paths) {
|
|
557
|
+
let lastErr;
|
|
558
|
+
for (const p of paths) {
|
|
559
|
+
try {
|
|
560
|
+
const data = await readFile(p);
|
|
561
|
+
return { path: p, data };
|
|
562
|
+
} catch (err) {
|
|
563
|
+
lastErr = err;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
throw lastErr instanceof Error ? lastErr : new Error("asset not found");
|
|
567
|
+
}
|
|
568
|
+
function makeAssetRouter(basePath) {
|
|
569
|
+
const router = Router4();
|
|
570
|
+
router.get("/browser.js", async (_req, res) => {
|
|
571
|
+
try {
|
|
572
|
+
const { data } = await readFirst(ASSET_CANDIDATES);
|
|
573
|
+
res.set("Content-Type", "application/javascript; charset=utf-8");
|
|
574
|
+
res.set("Cache-Control", "public, max-age=300");
|
|
575
|
+
res.send(data);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
res.status(500).send("// coding-tab browser bundle missing \u2014 did you run `npm run build`?");
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
router.get("/style.css", async (_req, res) => {
|
|
581
|
+
try {
|
|
582
|
+
const { data } = await readFirst(STYLE_CANDIDATES);
|
|
583
|
+
res.set("Content-Type", "text/css; charset=utf-8");
|
|
584
|
+
res.set("Cache-Control", "public, max-age=300");
|
|
585
|
+
res.send(data);
|
|
586
|
+
} catch {
|
|
587
|
+
res.status(404).send("/* style.css missing */");
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
router.get("/", async (_req, res) => {
|
|
591
|
+
const html = renderHostHtml(basePath);
|
|
592
|
+
res.set("Content-Type", "text/html; charset=utf-8");
|
|
593
|
+
res.send(html);
|
|
594
|
+
});
|
|
595
|
+
return router;
|
|
596
|
+
}
|
|
597
|
+
function renderHostHtml(basePath) {
|
|
598
|
+
return `<!doctype html>
|
|
599
|
+
<html lang="en">
|
|
600
|
+
<head>
|
|
601
|
+
<meta charset="utf-8" />
|
|
602
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
603
|
+
<title>Coding Tab</title>
|
|
604
|
+
<link rel="stylesheet" href="${basePath}/style.css" />
|
|
605
|
+
</head>
|
|
606
|
+
<body>
|
|
607
|
+
<div id="coding-tab-root"></div>
|
|
608
|
+
<script src="${basePath}/browser.js"></script>
|
|
609
|
+
<script>
|
|
610
|
+
window.CodingTab.mountCodingTab(
|
|
611
|
+
document.getElementById("coding-tab-root"),
|
|
612
|
+
{ apiBase: ${JSON.stringify(basePath)} },
|
|
613
|
+
);
|
|
614
|
+
</script>
|
|
615
|
+
</body>
|
|
616
|
+
</html>`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/server/index.ts
|
|
620
|
+
function mountCodingTab(app, options) {
|
|
621
|
+
const basePath = (options.basePath ?? "/coding-tab").replace(/\/$/, "");
|
|
622
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
623
|
+
if (!options.cursorApiKey) throw new Error("[coding-tab] cursorApiKey is required");
|
|
624
|
+
if (!options.sessionPassword || options.sessionPassword.length < 32) {
|
|
625
|
+
throw new Error("[coding-tab] sessionPassword must be at least 32 characters");
|
|
626
|
+
}
|
|
627
|
+
if (!options.githubOAuth?.clientId || !options.githubOAuth?.clientSecret) {
|
|
628
|
+
throw new Error("[coding-tab] githubOAuth.clientId and clientSecret are required");
|
|
629
|
+
}
|
|
630
|
+
if (!options.githubOAuth.callbackUrl) {
|
|
631
|
+
throw new Error("[coding-tab] githubOAuth.callbackUrl is required");
|
|
632
|
+
}
|
|
633
|
+
if (!options.githubOAuth.allowedLogins || options.githubOAuth.allowedLogins.length === 0) {
|
|
634
|
+
console.warn("[coding-tab] WARNING: allowedLogins is empty \u2014 anyone with a GitHub account can sign in.");
|
|
635
|
+
}
|
|
636
|
+
const router = express.Router();
|
|
637
|
+
router.use(express.json({ limit: "1mb" }));
|
|
638
|
+
const assetRouter = makeAssetRouter(basePath);
|
|
639
|
+
router.use(assetRouter);
|
|
640
|
+
const authRouter = makeAuthRouter({
|
|
641
|
+
oauth: options.githubOAuth,
|
|
642
|
+
sessionPassword: options.sessionPassword,
|
|
643
|
+
secure,
|
|
644
|
+
basePath
|
|
645
|
+
});
|
|
646
|
+
router.use(authRouter);
|
|
647
|
+
const requireAuth = makeRequireAuth({
|
|
648
|
+
sessionPassword: options.sessionPassword,
|
|
649
|
+
secure,
|
|
650
|
+
allowedLogins: options.githubOAuth.allowedLogins
|
|
651
|
+
});
|
|
652
|
+
const agentRouter = makeAgentRouter({
|
|
653
|
+
cursorApiKey: options.cursorApiKey,
|
|
654
|
+
defaultRepo: options.defaultRepo,
|
|
655
|
+
envName: options.envName,
|
|
656
|
+
skipReviewerRequest: options.skipReviewerRequest
|
|
657
|
+
});
|
|
658
|
+
router.use(requireAuth, agentRouter);
|
|
659
|
+
const githubRouter = makeGitHubRouter();
|
|
660
|
+
router.use(requireAuth, githubRouter);
|
|
661
|
+
app.use(basePath, router);
|
|
662
|
+
console.log(`[coding-tab] mounted at ${basePath}`);
|
|
663
|
+
return router;
|
|
664
|
+
}
|
|
665
|
+
export {
|
|
666
|
+
mountCodingTab
|
|
667
|
+
};
|
|
668
|
+
//# sourceMappingURL=server.js.map
|