Skip to content

Commit 33cfd87

Browse files
authored
Add minimal WebSocket server for Git operations
1 parent 0c38022 commit 33cfd87

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

BASE_WORKSPACE_DIR

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Minimal WebSocket server that accepts action messages from a browser IDE
3+
* and performs Git/GitHub operations with live streamed logs back to the client.
4+
*
5+
* Environment:
6+
* - PORT (default 3000)
7+
* - BASE_WORKSPACE_DIR (default ./workspaces)
8+
*
9+
* Security notes:
10+
* - The client must send a GitHub Personal Access Token (PAT) via an `auth`
11+
* message. Keep PATs scoped to minimum permissions (repo, workflow).
12+
* - This example uses token-in-URL for git clone/push to simplify authentication.
13+
* That exposes the token to the process environment for the command; in
14+
* production use more secure approaches (ssh keys, ephemeral tokens, or a vault).
15+
*
16+
* Protocol (JSON messages over WS):
17+
* - auth: { type: "auth", token: "<gh-token>" }
18+
* - run clone: { type: "run", action: "clone", payload: { owner, repo, branch } }
19+
* - run write: { type: "run", action: "write", payload: { workspaceId, path, content } }
20+
* - run git-commit: { type: "run", action: "commit", payload: { workspaceId, message, authorName, authorEmail } }
21+
* - run push: { type: "run", action: "push", payload: { workspaceId, branch } }
22+
* - run pr: { type: "run", action: "pr", payload: { workspaceId, headBranch, baseBranch, title, body } }
23+
* - run workflow_dispatch: { type: "run", action: "workflow_dispatch", payload: { owner, repo, workflow_id, ref, inputs } }
24+
*
25+
* Responses streamed back to the WebSocket as JSON:
26+
* - { type: "log", level: "info|error", text: "..." }
27+
* - { type: "result", action: "...", data: { ... } }
28+
* - { type: "error", message: "..." }
29+
*
30+
* This file is intentionally compact; adapt and harden for production.
31+
*/
32+
33+
import express from "express";
34+
import http from "http";
35+
import { WebSocketServer } from "ws";
36+
import { Octokit } from "@octokit/rest";
37+
import { spawn } from "child_process";
38+
import fs from "fs/promises";
39+
import path from "path";
40+
import { randomUUID } from "crypto";
41+
import dotenv from "dotenv";
42+
43+
dotenv.config();
44+
45+
const PORT = Number(process.env.PORT || 3000);
46+
const BASE_WS = process.env.BASE_WORKSPACE_DIR || path.resolve(process.cwd(), "workspaces");
47+
48+
// ensure base workspace exists
49+
await fs.mkdir(BASE_WS, { recursive: true });
50+
51+
const app = express();
52+
// serve static client (index.html) and assets
53+
app.use(express.static(path.resolve(process.cwd(), "public")));
54+
55+
const server = http.createServer(app);
56+
const wss = new WebSocketServer({ server });
57+
58+
function send(ws, obj) {
59+
try {
60+
ws.send(JSON.stringify(obj));
61+
} catch (err) {
62+
// ignore send errors
63+
}
64+
}
65+
66+
function maskTokenForLogs(url) {
67+
return url.replace(/\/\/.*@/, "//<token>@");
68+
}
69+
70+
async function execCommand(ws, cmd, args, cwd, opts = {}) {
71+
return new Promise((resolve) => {
72+
send(ws, { type: "log", level: "info", text: `+ ${[cmd, ...args].join(" ")}` });
73+
const child = spawn(cmd, args, { cwd, env: { ...process.env, ...opts.env }, shell: false });
74+
child.stdout.on("data", (d) => {
75+
send(ws, { type: "log", level: "info", text: d.toString() });
76+
});
77+
child.stderr.on("data", (d) => {
78+
send(ws, { type: "log", level: "error", text: d.toString() });
79+
});
80+
child.on("close", (code) => {
81+
send(ws, { type: "log", level: "info", text: `process exited ${code}` });
82+
resolve({ code });
83+
});
84+
});
85+
}
86+
87+
wss.on("connection", (ws) => {
88+
ws.session = { authenticated: false, token: null, workspaces: {} };
89+
send(ws, { type: "log", level: "info", text: "connected to github-live-runner-ws" });
90+
91+
ws.on("message", async (raw) => {
92+
let msg;
93+
try {
94+
msg = JSON.parse(raw.toString());
95+
} catch (e) {
96+
send(ws, { type: "error", message: "invalid JSON" });
97+
return;
98+
}
99+
100+
if (msg.type === "auth") {
101+
const token = msg.token;
102+
if (!token) {
103+
send(ws, { type: "error", message: "token required" });
104+
return;
105+
}
106+
ws.session.token = token;
107+
ws.session.octokit = new Octokit({ auth: token });
108+
ws.session.authenticated = true;
109+
send(ws, { type: "result", action: "auth", data: { authenticated: true } });
110+
return;
111+
}
112+
113+
if (!ws.session.authenticated) {
114+
send(ws, { type: "error", message: "not authenticated - send {type:'auth', token: '...'}" });
115+
return;
116+
}
117+
118+
if (msg.type === "run") {
119+
const action = msg.action;
120+
const payload = msg.payload || {};
121+
try {
122+
if (action === "clone") {
123+
const { owner, repo, branch } = payload;
124+
if (!owner || !repo) throw new Error("owner and repo required");
125+
const workspaceId = randomUUID();
126+
const repoDir = path.join(BASE_WS, workspaceId);
127+
await fs.mkdir(repoDir, { recursive: true });
128+
const token = ws.session.token;
129+
// Use token in URL for git authentication. In production, use more secure methods.
130+
const cloneUrl = `https://${token}@github.com/${owner}/${repo}.git`;
131+
send(ws, { type: "log", level: "info", text: `cloning ${owner}/${repo} into ${repoDir}` });
132+
await execCommand(ws, "git", ["clone", "--depth", "1", ...(branch ? ["-b", branch] : []), cloneUrl, "."], repoDir, { env: {} });
133+
ws.session.workspaces[workspaceId] = { path: repoDir, owner, repo };
134+
send(ws, { type: "result", action: "clone", data: { workspaceId, path: repoDir } });
135+
} else if (action === "write") {
136+
const { workspaceId, path: filePath, content } = payload;
137+
if (!workspaceId || !filePath) throw new Error("workspaceId and path required");
138+
const wsInfo = ws.session.workspaces[workspaceId];
139+
if (!wsInfo) throw new Error("workspace not found");
140+
const abs = path.join(wsInfo.path, filePath);
141+
await fs.mkdir(path.dirname(abs), { recursive: true });
142+
await fs.writeFile(abs, content || "", "utf8");
143+
send(ws, { type: "result", action: "write", data: { path: abs } });
144+
} else if (action === "commit") {
145+
const { workspaceId, message = "update from ws", authorName = "ws-runner", authorEmail = "ws@example.com" } = payload;
146+
if (!workspaceId) throw new Error("workspaceId required");
147+
const wsInfo = ws.session.workspaces[workspaceId];
148+
if (!wsInfo) throw new Error("workspace not found");
149+
await execCommand(ws, "git", ["add", "."], wsInfo.path);
150+
await execCommand(ws, "git", ["commit", "-m", message, "--author", `${authorName} <${authorEmail}>`], wsInfo.path);
151+
send(ws, { type: "result", action: "commit", data: {} });
152+
} else if (action === "push") {
153+
const { workspaceId, branch = "main" } = payload;
154+
if (!workspaceId) throw new Error("workspaceId required");
155+
const wsInfo = ws.session.workspaces[workspaceId];
156+
if (!wsInfo) throw new Error("workspace not found");
157+
// set upstream branch if necessary
158+
await execCommand(ws, "git", ["push", "origin", `HEAD:${branch}`], wsInfo.path);
159+
send(ws, { type: "result", action: "push", data: {} });
160+
} else if (action === "pr") {
161+
const { workspaceId, headBranch, baseBranch = "main", title = "Automated PR", body = "" } = payload;
162+
if (!workspaceId || !headBranch) throw new Error("workspaceId and headBranch required");
163+
const wsInfo = ws.session.workspaces[workspaceId];
164+
if (!wsInfo) throw new Error("workspace not found");
165+
const octokit = ws.session.octokit;
166+
const resp = await octokit.pulls.create({
167+
owner: wsInfo.owner,
168+
repo: wsInfo.repo,
169+
head: headBranch,
170+
base: baseBranch,
171+
title,
172+
body
173+
});
174+
send(ws, { type: "result", action: "pr", data: { url: resp.data.html_url } });
175+
} else if (action === "workflow_dispatch") {
176+
const { owner, repo, workflow_id, ref = "main", inputs = {} } = payload;
177+
if (!owner || !repo || !workflow_id) throw new Error("owner, repo and workflow_id required");
178+
const octokit = ws.session.octokit;
179+
await octokit.actions.createWorkflowDispatch({
180+
owner,
181+
repo,
182+
workflow_id,
183+
ref,
184+
inputs
185+
});
186+
send(ws, { type: "result", action: "workflow_dispatch", data: { triggered: true } });
187+
} else {
188+
send(ws, { type: "error", message: `unknown action ${action}` });
189+
}
190+
} catch (err) {
191+
send(ws, { type: "error", message: err.message || String(err) });
192+
}
193+
return;
194+
}
195+
196+
send(ws, { type: "error", message: "unknown message type" });
197+
});
198+
199+
ws.on("close", () => {
200+
// cleanup could be done here for ephemeral workspaces
201+
});
202+
});
203+
204+
server.listen(PORT, () => {
205+
// prettier-ignore
206+
// console log intentionally minimal
207+
console.log(`Server listening on http://localhost:${PORT}`);
208+
});

0 commit comments

Comments
 (0)