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.

208 lines · 7.5 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 /** Stands in for the browser when the CLI opens one. */
111 openUrl?: (url: string) => Promise<boolean>;
112 selfPath?: string;
113}
114
115/** An isolated environment: HOME/XDG in a temp dir, git with no global config. */
116export function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
117 const home = tempDir("diffinery-home-");
118 const gitconfig = path.join(home, ".gitconfig");
119 writeFileSync(gitconfig, "");
120 return {
121 PATH: process.env.PATH,
122 HOME: home,
123 XDG_CONFIG_HOME: path.join(home, ".config"),
124 GIT_CONFIG_GLOBAL: gitconfig,
125 GIT_CONFIG_NOSYSTEM: "1",
126 GIT_AUTHOR_NAME: "Test",
127 GIT_AUTHOR_EMAIL: "test@example.com",
128 GIT_COMMITTER_NAME: "Test",
129 GIT_COMMITTER_EMAIL: "test@example.com",
130 ...extra,
131 };
132}
133
134export async function run(argv: string[], o: RunOptions = {}): Promise<RunResult> {
135 let stdout = "";
136 let stderr = "";
137 const stdin = Readable.from(o.stdin !== undefined ? [Buffer.from(o.stdin)] : []) as Io["stdin"];
138 stdin.isTTY = o.stdinTTY ?? false;
139 const io: Io = {
140 env: o.env ?? isolatedEnv(),
141 cwd: o.cwd ?? tempDir(),
142 stdin,
143 stdout: { write: (s: string) => ((stdout += s), true) },
144 stderr: { write: (s: string) => ((stderr += s), true) },
145 sleep: () => new Promise((r) => setTimeout(r, 1)),
146 openUrl: o.openUrl,
147 selfPath: o.selfPath,
148 };
149 const code = await main(argv, io);
150 return { code, stdout, stderr };
151}
152
153/** Creates a git repository whose origin points at `remote`, on branch `branch`. */
154export function gitRepo(env: NodeJS.ProcessEnv, remote: string, branch = "feature/x"): string {
155 const dir = tempDir("diffinery-repo-");
156 const g = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" });
157 g("init", "-q", "-b", "main");
158 g("remote", "add", "origin", remote);
159 g("commit", "-q", "--allow-empty", "-m", "init");
160 g("checkout", "-q", "-b", branch);
161 return dir;
162}
163
164/** Serves git smart HTTP via `git http-backend` (CGI), requiring `auth` as the Authorization header when set. */
165async function gitBackend(root: string, auth: string | undefined, req: IncomingMessage, res: ServerResponse, requests: Recorded[]): Promise<void> {
166 const u = new URL(req.url ?? "/", "http://x");
167 requests.push({ method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body: undefined });
168 if (auth && req.headers.authorization !== auth) {
169 res.writeHead(401, { "www-authenticate": 'Basic realm="test"' });
170 return void res.end();
171 }
172 const child = spawn("git", ["http-backend"], {
173 env: {
174 PATH: process.env.PATH,
175 GIT_PROJECT_ROOT: root,
176 GIT_HTTP_EXPORT_ALL: "1",
177 GIT_CONFIG_NOSYSTEM: "1",
178 GIT_CONFIG_GLOBAL: "/dev/null",
179 REQUEST_METHOD: req.method ?? "GET",
180 PATH_INFO: decodeURIComponent(u.pathname),
181 QUERY_STRING: u.search.slice(1),
182 CONTENT_TYPE: req.headers["content-type"] ?? "",
183 HTTP_CONTENT_ENCODING: String(req.headers["content-encoding"] ?? ""),
184 GIT_PROTOCOL: String(req.headers["git-protocol"] ?? ""),
185 REMOTE_USER: "tester",
186 REMOTE_ADDR: "127.0.0.1",
187 },
188 stdio: ["pipe", "pipe", "inherit"],
189 });
190 req.pipe(child.stdin);
191 const chunks: Buffer[] = [];
192 for await (const c of child.stdout) chunks.push(c as Buffer);
193 const out = Buffer.concat(chunks);
194 const sep = out.indexOf("\r\n\r\n");
195 const head = out.subarray(0, sep).toString();
196 const headers: Record<string, string> = {};
197 let status = 200;
198 for (const line of head.split("\r\n")) {
199 const i = line.indexOf(":");
200 if (i < 0) continue;
201 const k = line.slice(0, i).trim();
202 const v = line.slice(i + 1).trim();
203 if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]);
204 else headers[k] = v;
205 }
206 res.writeHead(status, headers);
207 res.end(out.subarray(sep + 4));
208}