|
| 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