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.

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