Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

203 lines · 7.3 KBRaw History
1import { execFileSync, spawn } from "node:child_process";
2import { mkdtempSync, writeFileSync } from "node:fs";
3import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4import type { AddressInfo } from "node:net";
5import { tmpdir } from "node:os";
6import path from "node:path";
7import { Readable } from "node:stream";
8import type { Io } from "../src/io.js";
9import { main } from "../src/main.js";
10
11export interface Recorded {
12 method: string;
13 path: string;
14 query: Record<string, string>;
15 headers: IncomingMessage["headers"];
16 body: unknown;
17}
18
19export interface Reply {
20 status?: number;
21 body?: unknown;
22 headers?: Record<string, string>;
23}
24
25type Handler = (req: Recorded) => Reply | Promise<Reply>;
26
27export interface FakeServer {
28 url: string;
29 host: string;
30 requests: Recorded[];
31 on(method: string, path: string | RegExp, h: Handler | Reply): void;
32 find(method: string, path: string | RegExp): Recorded[];
33 close(): Promise<void>;
34}
35
36/**
37 * A tiny programmable HTTP server: unmatched routes return 404 { message }.
38 * With `gitRoot`, paths containing `.git/` are served by `git http-backend` from that directory.
39 */
40export async function fakeServer(o: { gitRoot?: string; gitAuth?: string } = {}): Promise<FakeServer> {
41 const routes: Array<{ method: string; path: string | RegExp; h: Handler }> = [];
42 const requests: Recorded[] = [];
43 const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? p === path : p.test(path));
44 const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
45 if (o.gitRoot && /\.git\//.test(req.url ?? "")) return gitBackend(o.gitRoot, o.gitAuth, req, res, requests);
46 const chunks: Buffer[] = [];
47 for await (const c of req) chunks.push(c as Buffer);
48 const text = Buffer.concat(chunks).toString("utf8");
49 let body: unknown = text || undefined;
50 try {
51 if (text) body = JSON.parse(text);
52 } catch {
53 /* keep text */
54 }
55 const u = new URL(req.url ?? "/", "http://x");
56 const rec: Recorded = { method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body };
57 requests.push(rec);
58 const route = [...routes].reverse().find((r) => r.method === rec.method && matches(r.path, rec.path));
59 const reply: Reply = route ? await route.h(rec) : { status: 404, body: { message: "not found" } };
60 const status = reply.status ?? 200;
61 const headers: Record<string, string> = { ...reply.headers };
62 let out = "";
63 if (reply.body !== undefined && status !== 204) {
64 if (typeof reply.body === "string") {
65 out = reply.body;
66 headers["content-type"] ??= "text/plain";
67 } else {
68 out = JSON.stringify(reply.body);
69 headers["content-type"] ??= "application/json";
70 }
71 }
72 res.writeHead(status, headers);
73 res.end(out);
74 });
75 await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
76 const port = (server.address() as AddressInfo).port;
77 return {
78 url: `http://127.0.0.1:${port}`,
79 host: `127.0.0.1:${port}`,
80 requests,
81 on(method, path, h) {
82 routes.push({ method, path, h: typeof h === "function" ? h : () => h });
83 },
84 find(method, path) {
85 return requests.filter((r) => r.method === method && matches(path, r.path));
86 },
87 close: () =>
88 new Promise<void>((r) => {
89 server.closeAllConnections();
90 server.close(() => r());
91 }),
92 };
93}
94
95export function tempDir(prefix = "diffinery-cli-"): string {
96 return mkdtempSync(path.join(tmpdir(), prefix));
97}
98
99export interface RunResult {
100 code: number;
101 stdout: string;
102 stderr: string;
103}
104
105export interface RunOptions {
106 env?: NodeJS.ProcessEnv;
107 cwd?: string;
108 stdin?: string;
109 stdinTTY?: boolean;
110}
111
112/** An isolated environment: HOME/XDG in a temp dir, git with no global config. */
113export function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
114 const home = tempDir("diffinery-home-");
115 const gitconfig = path.join(home, ".gitconfig");
116 writeFileSync(gitconfig, "");
117 return {
118 PATH: process.env.PATH,
119 HOME: home,
120 XDG_CONFIG_HOME: path.join(home, ".config"),
121 GIT_CONFIG_GLOBAL: gitconfig,
122 GIT_CONFIG_NOSYSTEM: "1",
123 GIT_AUTHOR_NAME: "Test",
124 GIT_AUTHOR_EMAIL: "test@example.com",
125 GIT_COMMITTER_NAME: "Test",
126 GIT_COMMITTER_EMAIL: "test@example.com",
127 ...extra,
128 };
129}
130
131export async function run(argv: string[], o: RunOptions = {}): Promise<RunResult> {
132 let stdout = "";
133 let stderr = "";
134 const stdin = Readable.from(o.stdin !== undefined ? [Buffer.from(o.stdin)] : []) as Io["stdin"];
135 stdin.isTTY = o.stdinTTY ?? false;
136 const io: Io = {
137 env: o.env ?? isolatedEnv(),
138 cwd: o.cwd ?? tempDir(),
139 stdin,
140 stdout: { write: (s: string) => ((stdout += s), true) },
141 stderr: { write: (s: string) => ((stderr += s), true) },
142 sleep: () => new Promise((r) => setTimeout(r, 1)),
143 };
144 const code = await main(argv, io);
145 return { code, stdout, stderr };
146}
147
148/** Creates a git repository whose origin points at `remote`, on branch `branch`. */
149export function gitRepo(env: NodeJS.ProcessEnv, remote: string, branch = "feature/x"): string {
150 const dir = tempDir("diffinery-repo-");
151 const g = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" });
152 g("init", "-q", "-b", "main");
153 g("remote", "add", "origin", remote);
154 g("commit", "-q", "--allow-empty", "-m", "init");
155 g("checkout", "-q", "-b", branch);
156 return dir;
157}
158
159/** Serves git smart HTTP via `git http-backend` (CGI), requiring `auth` as the Authorization header when set. */
160async function gitBackend(root: string, auth: string | undefined, req: IncomingMessage, res: ServerResponse, requests: Recorded[]): Promise<void> {
161 const u = new URL(req.url ?? "/", "http://x");
162 requests.push({ method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body: undefined });
163 if (auth && req.headers.authorization !== auth) {
164 res.writeHead(401, { "www-authenticate": 'Basic realm="test"' });
165 return void res.end();
166 }
167 const child = spawn("git", ["http-backend"], {
168 env: {
169 PATH: process.env.PATH,
170 GIT_PROJECT_ROOT: root,
171 GIT_HTTP_EXPORT_ALL: "1",
172 GIT_CONFIG_NOSYSTEM: "1",
173 GIT_CONFIG_GLOBAL: "/dev/null",
174 REQUEST_METHOD: req.method ?? "GET",
175 PATH_INFO: decodeURIComponent(u.pathname),
176 QUERY_STRING: u.search.slice(1),
177 CONTENT_TYPE: req.headers["content-type"] ?? "",
178 HTTP_CONTENT_ENCODING: String(req.headers["content-encoding"] ?? ""),
179 GIT_PROTOCOL: String(req.headers["git-protocol"] ?? ""),
180 REMOTE_USER: "tester",
181 REMOTE_ADDR: "127.0.0.1",
182 },
183 stdio: ["pipe", "pipe", "inherit"],
184 });
185 req.pipe(child.stdin);
186 const chunks: Buffer[] = [];
187 for await (const c of child.stdout) chunks.push(c as Buffer);
188 const out = Buffer.concat(chunks);
189 const sep = out.indexOf("\r\n\r\n");
190 const head = out.subarray(0, sep).toString();
191 const headers: Record<string, string> = {};
192 let status = 200;
193 for (const line of head.split("\r\n")) {
194 const i = line.indexOf(":");
195 if (i < 0) continue;
196 const k = line.slice(0, i).trim();
197 const v = line.slice(i + 1).trim();
198 if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]);
199 else headers[k] = v;
200 }
201 res.writeHead(status, headers);
202 res.end(out.subarray(sep + 4));
203}