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.

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