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.

Web UI, reserved logins, image build fixes; e2e covers web pages and runs jobs in the runner image

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 851531dcommit b3a19be36ded66f167f82a64c9cf416abce4cc71Browse files
21 files changed+887−50
Files (21)
modifiedapps/cli/package.json+1−1View file
@@ -8,7 +8,7 @@
88 "files": ["dist"],
99 "engines": { "node": ">=22" },
1010 "scripts": {
11- "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"",
11+ "build": "esbuild src/bin.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"",
1212 "typecheck": "tsc --noEmit",
1313 "test": "vitest run"
1414 },
addedapps/cli/src/bin.ts+16−0View file
@@ -1,0 +1,16 @@
1+import { main } from "./main.js";
2+
3+// EPIPE when piping into `head` and similar should end quietly.
4+process.stdout.on("error", (e: NodeJS.ErrnoException) => {
5+ if (e.code === "EPIPE") process.exit(0);
6+});
7+
8+main(process.argv.slice(2)).then(
9+ (code) => {
10+ process.exitCode = code;
11+ },
12+ (e: unknown) => {
13+ process.stderr.write(`error: ${String(e)}\n`);
14+ process.exitCode = 1;
15+ },
16+);
modifiedapps/cli/src/cli.ts+1−1View file
@@ -206,7 +206,7 @@
206206 try {
207207 const parsed = parseArgs({
208208 args: argv,
209- options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, short: o.short, multiple: o.multiple }])),
209+ options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])),
210210 allowPositionals: true,
211211 strict: true,
212212 });
modifiedapps/cli/src/commands/pr.ts+4−1View file
@@ -185,7 +185,10 @@
185185 async run(ctx, o, [num]) {
186186 const repo = await ctx.repo();
187187 const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${positiveInt(num, "pull request number")}/files`);
188- if (o["name-only"]) return ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
188+ if (o["name-only"]) {
189+ ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
190+ return;
191+ }
189192 const c = ctx.c;
190193 for (const f of files) {
191194 const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`;
modifiedapps/cli/src/git.ts+7−4View file
@@ -12,17 +12,20 @@
1212 return new Promise((resolve) => {
1313 let child;
1414 try {
15- child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["pipe", "pipe", "pipe"] });
15+ child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
1616 } catch (e) {
1717 return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) });
1818 }
1919 let stdout = "";
2020 let stderr = "";
21- child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
22- child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
21+ child.stdout?.on("data", (d: Buffer) => (stdout += d.toString()));
22+ child.stderr?.on("data", (d: Buffer) => (stderr += d.toString()));
2323 child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message }));
2424 child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
25- child.stdin.end(o.input ?? "");
25+ if (child.stdin) {
26+ child.stdin.on("error", () => {}); // git may exit before reading its input
27+ child.stdin.end(o.input);
28+ }
2629 });
2730 }
2831
modifiedapps/cli/src/main.ts+0−22View file
@@ -28,25 +28,3 @@
2828 return 1;
2929 }
3030 }
31-
32-const isEntry = (() => {
33- const entry = process.argv[1];
34- if (!entry) return false;
35- return /(?:^|[\\/])(?:diffinery(?:\.mjs)?|main\.[cm]?[jt]s)$/.test(entry);
36-})();
37-
38-if (isEntry) {
39- // EPIPE when piping into `head` and similar should end quietly.
40- process.stdout.on("error", (e: NodeJS.ErrnoException) => {
41- if (e.code === "EPIPE") process.exit(0);
42- });
43- main(process.argv.slice(2)).then(
44- (code) => {
45- process.exitCode = code;
46- },
47- (e: unknown) => {
48- process.stderr.write(`error: ${String(e)}\n`);
49- process.exitCode = 1;
50- },
51- );
52-}
addedapps/cli/test/config.test.ts+109−0View file
@@ -1,0 +1,109 @@
1+import { mkdirSync, statSync, writeFileSync, readFileSync } from "node:fs";
2+import path from "node:path";
3+import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+import { configPath, normalizeUrl, resolveConfig, writeConfig } from "../src/config.js";
5+import type { Io } from "../src/io.js";
6+import { fakeServer, isolatedEnv, run, type FakeServer } from "./helpers.js";
7+
8+const ioWith = (env: NodeJS.ProcessEnv) => ({ env }) as unknown as Io;
9+
10+describe("config", () => {
11+ it("defaults to https://diffinery.com with no token", async () => {
12+ const cfg = await resolveConfig(ioWith(isolatedEnv()));
13+ expect(cfg).toMatchObject({ url: "https://diffinery.com", token: null, urlSource: "default", tokenSource: null });
14+ });
15+
16+ it("reads the file under XDG_CONFIG_HOME and lets env vars override it", async () => {
17+ const env = isolatedEnv();
18+ const file = path.join(env.XDG_CONFIG_HOME!, "diffinery", "config.json");
19+ expect(configPath(env)).toBe(file);
20+ mkdirSync(path.dirname(file), { recursive: true });
21+ writeFileSync(file, JSON.stringify({ url: "https://forge.example.com/", token: "dfy_file" }));
22+
23+ expect(await resolveConfig(ioWith(env))).toMatchObject({ url: "https://forge.example.com", token: "dfy_file", urlSource: "config", tokenSource: "config" });
24+ expect(await resolveConfig(ioWith({ ...env, DIFFINERY_TOKEN: "dfy_env" }))).toMatchObject({ url: "https://forge.example.com", token: "dfy_env", tokenSource: "env" });
25+ expect(await resolveConfig(ioWith({ ...env, DIFFINERY_URL: "other.example.com" }))).toMatchObject({ url: "https://other.example.com", token: "dfy_file", urlSource: "env" });
26+ });
27+
28+ it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => {
29+ const env = isolatedEnv();
30+ delete env.XDG_CONFIG_HOME;
31+ expect(configPath(env)).toBe(path.join(env.HOME!, ".config", "diffinery", "config.json"));
32+ });
33+
34+ it("writes the config with mode 0600", async () => {
35+ const env = isolatedEnv();
36+ const file = await writeConfig(env, { url: "https://a.example", token: "dfy_x" });
37+ expect(statSync(file).mode & 0o777).toBe(0o600);
38+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_x" });
39+ });
40+
41+ it("normalizes URLs", () => {
42+ expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com");
43+ expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787");
44+ expect(() => normalizeUrl("ftp://x")).toThrow();
45+ });
46+});
47+
48+describe("login / whoami / logout", () => {
49+ let srv: FakeServer;
50+ beforeEach(async () => {
51+ srv = await fakeServer();
52+ srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", name: "Alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } }));
53+ });
54+ afterEach(() => srv.close());
55+
56+ it("verifies the token, saves it 0600, and whoami uses it", async () => {
57+ const env = isolatedEnv();
58+ const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env });
59+ expect(r.code).toBe(0);
60+ expect(r.stdout).toContain("Logged in to");
61+ expect(r.stdout).toContain("alice");
62+ expect(r.stdout).not.toContain("dfy_good");
63+ const file = configPath(env);
64+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" });
65+ expect(statSync(file).mode & 0o777).toBe(0o600);
66+
67+ const who = await run(["whoami"], { env });
68+ expect(who.code).toBe(0);
69+ expect(who.stdout).toContain("alice");
70+ expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good");
71+
72+ const out = await run(["logout"], { env });
73+ expect(out.code).toBe(0);
74+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url });
75+ expect((await run(["whoami"], { env })).code).toBe(1);
76+ });
77+
78+ it("reads the token from stdin when it is not a TTY", async () => {
79+ const env = isolatedEnv();
80+ const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" });
81+ expect(r.code).toBe(0);
82+ expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good");
83+ });
84+
85+ it("refuses a token the server rejects and saves nothing", async () => {
86+ const env = isolatedEnv();
87+ const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env });
88+ expect(r.code).toBe(1);
89+ expect(r.stderr).toContain("bad credentials");
90+ expect(() => readFileSync(configPath(env))).toThrow();
91+ });
92+
93+ it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => {
94+ const env = isolatedEnv();
95+ await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" });
96+ const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } });
97+ expect(r.code).toBe(0);
98+ expect(JSON.parse(r.stdout).login).toBe("alice");
99+ });
100+
101+ it("mcp-config prints a snippet without the token unless asked", async () => {
102+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" });
103+ const r = await run(["mcp-config"], { env });
104+ const cfg = JSON.parse(r.stdout);
105+ expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } });
106+ const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout);
107+ expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good");
108+ });
109+});
addedapps/cli/test/credential.test.ts+54−0View file
@@ -1,0 +1,54 @@
1+import { execFileSync } from "node:child_process";
2+import { describe, expect, it } from "vitest";
3+import { parseCredentialInput } from "../src/commands/auth.js";
4+import { isolatedEnv, run } from "./helpers.js";
5+
6+describe("git credential helper", () => {
7+ const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" });
8+
9+ it("parses key=value lines up to the blank line", () => {
10+ expect(parseCredentialInput("protocol=https\nhost=a.b\npath=x/y.git\n\nignored=1\n")).toEqual({ protocol: "https", host: "a.b", path: "x/y.git" });
11+ });
12+
13+ it("answers get for the configured host", async () => {
14+ const r = await run(["auth", "git-credential", "get"], { env, stdin: "protocol=https\nhost=forge.example.com\n\n" });
15+ expect(r.code).toBe(0);
16+ expect(r.stdout).toBe("protocol=https\nhost=forge.example.com\nusername=x-token\npassword=dfy_secret\n");
17+ });
18+
19+ it("stays silent for other hosts, other protocols and other operations", async () => {
20+ for (const stdin of ["protocol=https\nhost=github.com\n\n", "protocol=http\nhost=forge.example.com\n\n"]) {
21+ const r = await run(["auth", "git-credential", "get"], { env, stdin });
22+ expect(r.code).toBe(0);
23+ expect(r.stdout).toBe("");
24+ }
25+ for (const op of ["store", "erase"]) {
26+ const r = await run(["auth", "git-credential", op], { env, stdin: "protocol=https\nhost=forge.example.com\nusername=x\npassword=y\n\n" });
27+ expect(r.code).toBe(0);
28+ expect(r.stdout).toBe("");
29+ }
30+ });
31+
32+ it("matches hosts with ports", async () => {
33+ const e = { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" };
34+ const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=http\nhost=127.0.0.1:8787\n" });
35+ expect(r.stdout).toContain("password=dfy_secret");
36+ });
37+
38+ it("prints nothing without a token", async () => {
39+ const e = { ...env };
40+ delete e.DIFFINERY_TOKEN;
41+ const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=https\nhost=forge.example.com\n" });
42+ expect(r.stdout).toBe("");
43+ });
44+
45+ it("auth setup-git configures a URL-scoped helper in the global git config", async () => {
46+ const r = await run(["auth", "setup-git"], { env });
47+ expect(r.code).toBe(0);
48+ const helpers = execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n");
49+ expect(helpers.slice(0, 2)).toEqual(["", "!diffinery auth git-credential"]);
50+ // Idempotent.
51+ await run(["auth", "setup-git"], { env });
52+ expect(execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n").slice(0, -1)).toEqual(["", "!diffinery auth git-credential"]);
53+ });
54+});
addedapps/cli/test/helpers.ts+203−0View file
@@ -1,0 +1,203 @@
1+import { execFileSync, spawn } from "node:child_process";
2+import { mkdtempSync, writeFileSync } from "node:fs";
3+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4+import type { AddressInfo } from "node:net";
5+import { tmpdir } from "node:os";
6+import path from "node:path";
7+import { Readable } from "node:stream";
8+import type { Io } from "../src/io.js";
9+import { main } from "../src/main.js";
10+
11+export interface Recorded {
12+ method: string;
13+ path: string;
14+ query: Record<string, string>;
15+ headers: IncomingMessage["headers"];
16+ body: unknown;
17+}
18+
19+export interface Reply {
20+ status?: number;
21+ body?: unknown;
22+ headers?: Record<string, string>;
23+}
24+
25+type Handler = (req: Recorded) => Reply | Promise<Reply>;
26+
27+export 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+ */
40+export 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+
95+export function tempDir(prefix = "diffinery-cli-"): string {
96+ return mkdtempSync(path.join(tmpdir(), prefix));
97+}
98+
99+export interface RunResult {
100+ code: number;
101+ stdout: string;
102+ stderr: string;
103+}
104+
105+export 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. */
113+export 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+
131+export 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`. */
149+export 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. */
160+async 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+}
addedapps/cli/test/misc.test.ts+138−0View file
@@ -1,0 +1,138 @@
1+import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+import { GitHub, nextLink } from "../src/github.js";
3+import { withGitConfig } from "../src/git.js";
4+import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js";
5+
6+describe("run watch", () => {
7+ let srv: FakeServer;
8+ beforeEach(async () => {
9+ srv = await fakeServer();
10+ });
11+ afterEach(() => srv.close());
12+
13+ const runBody = (status: string, conclusion: string | null, jobs: Array<[string, string, string | null]>) => ({
14+ id: "run1",
15+ run_number: 4,
16+ run_attempt: 1,
17+ name: "CI",
18+ display_title: "Fix bug",
19+ path: ".github/workflows/ci.yml",
20+ event: "push",
21+ head_branch: "main",
22+ head_sha: "abcdef1234",
23+ status,
24+ conclusion,
25+ actor: { login: "alice" },
26+ created_at: new Date().toISOString(),
27+ updated_at: new Date().toISOString(),
28+ html_url: "http://x/runs/run1",
29+ jobs: jobs.map(([id, s, c]) => ({ id, name: `job-${id}`, status: s, conclusion: c, startedAt: null, completedAt: null, steps: [], failure: c === "failure" ? { message: "exit 1" } : null })),
30+ });
31+
32+ it("prints job transitions and exits 1 when the run fails", async () => {
33+ const states = [
34+ runBody("queued", null, [["a", "queued", null]]),
35+ runBody("in_progress", null, [["a", "in_progress", null]]),
36+ runBody("in_progress", null, [["a", "in_progress", null]]),
37+ runBody("completed", "failure", [["a", "completed", "failure"]]),
38+ ];
39+ let i = 0;
40+ srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", () => ({ body: states[Math.min(i++, states.length - 1)] }));
41+ const r = await run(["run", "watch", "run1", "-R", "o/r", "--interval", "0"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) });
42+ expect(r.code).toBe(1);
43+ const lines = r.stdout.split("\n").filter((l) => l.includes("job-a"));
44+ expect(lines).toHaveLength(3); // queued, in progress, failure (no duplicate for the repeated state)
45+ expect(r.stdout).toContain("completed: failure");
46+ });
47+
48+ it("exits 0 on success", async () => {
49+ srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", { body: runBody("completed", "success", [["a", "completed", "success"]]) });
50+ const r = await run(["run", "watch", "run1", "-R", "o/r"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) });
51+ expect(r.code).toBe(0);
52+ });
53+});
54+
55+describe("GitHub client", () => {
56+ let srv: FakeServer;
57+ beforeEach(async () => {
58+ srv = await fakeServer();
59+ });
60+ afterEach(() => srv.close());
61+
62+ it("follows Link pagination and waits out rate limits", async () => {
63+ let hits = 0;
64+ srv.on("GET", "/repos/o/r/issues", (req): Reply => {
65+ hits++;
66+ if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } };
67+ if (req.query.page === "2") return { body: [{ number: 3 }] };
68+ return { body: [{ number: 1 }, { number: 2 }], headers: { link: `<${srv.url}/repos/o/r/issues?page=2&per_page=100>; rel="next", <${srv.url}/repos/o/r/issues?page=2>; rel="last"` } };
69+ });
70+ srv.on("GET", "/repos/o/r/actions/variables", (req) =>
71+ req.query.page === "2" ? { body: { total_count: 2, variables: [{ name: "B", value: "2" }] } } : { body: { total_count: 2, variables: [{ name: "A", value: "1" }] }, headers: { link: `<${srv.url}/repos/o/r/actions/variables?page=2>; rel="next"` } },
72+ );
73+ srv.on("GET", "/rate", () => (hits++ < 10 ? { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000" }, body: { message: "API rate limit exceeded" } } : { body: { ok: true } }));
74+ const waits: number[] = [];
75+ const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 });
76+ expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]);
77+ expect(waits).toEqual([2000]);
78+ expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght");
79+ expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" });
80+ expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([
81+ { name: "A", value: "1" },
82+ { name: "B", value: "2" },
83+ ]);
84+ hits = 5;
85+ waits.length = 0;
86+ expect(await gh.get("/rate")).toEqual({ ok: true });
87+ expect(waits.length).toBeGreaterThan(0);
88+ expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s
89+ });
90+
91+ it("parses Link headers", () => {
92+ expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2");
93+ expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull();
94+ expect(nextLink(null)).toBeNull();
95+ });
96+});
97+
98+describe("misc", () => {
99+ it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => {
100+ const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]);
101+ expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" });
102+ });
103+
104+ it("--help works for every command and --version prints the version", async () => {
105+ const { registry } = await import("../src/main.js");
106+ const env = isolatedEnv();
107+ for (const g of registry.groups) {
108+ expect((await run([g.name, "--help"], { env })).code).toBe(0);
109+ for (const c of g.commands) {
110+ const r = await run([...c.name.split(" "), "--help"], { env });
111+ expect(r.code, c.name).toBe(0);
112+ expect(r.stdout).toContain(`Usage: diffinery ${c.name}`);
113+ }
114+ }
115+ for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0);
116+ expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/);
117+ expect((await run(["nope"], { env })).code).toBe(2);
118+ });
119+
120+ it("api prints JSON and exits 1 on HTTP errors", async () => {
121+ const srv = await fakeServer();
122+ try {
123+ srv.on("GET", "/api/v3/user", { body: { login: "alice" } });
124+ srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body }));
125+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" });
126+ const r = await run(["api", "get", "/user"], { env });
127+ expect(r.code).toBe(0);
128+ expect(JSON.parse(r.stdout)).toEqual({ login: "alice" });
129+ const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' });
130+ expect(JSON.parse(p.stdout)).toEqual({ name: "bug" });
131+ const missing = await run(["api", "GET", "/nope"], { env });
132+ expect(missing.code).toBe(1);
133+ expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" });
134+ } finally {
135+ await srv.close();
136+ }
137+ });
138+});
addedapps/cli/test/pr.test.ts+119−0View file
@@ -1,0 +1,119 @@
1+import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+import { parseRemote } from "../src/git.js";
3+import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js";
4+
5+const pull = (n: number, extra: Record<string, unknown> = {}) => ({
6+ number: n,
7+ title: "Add thing",
8+ body: "",
9+ state: "open",
10+ user: { login: "alice", type: "User" },
11+ labels: [],
12+ assignees: [],
13+ comments: 0,
14+ created_at: new Date().toISOString(),
15+ updated_at: new Date().toISOString(),
16+ html_url: `http://x/acme/widgets/pull/${n}`,
17+ head: { ref: "feature/x", sha: "abc" },
18+ base: { ref: "main" },
19+ ...extra,
20+});
21+
22+describe("remote parsing", () => {
23+ it("recognizes https, ssh and scp-style remotes for the configured host", () => {
24+ expect(parseRemote("https://diffinery.com/acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" });
25+ expect(parseRemote("http://127.0.0.1:8787/acme/widgets", "127.0.0.1:8787")).toEqual({ owner: "acme", name: "widgets" });
26+ expect(parseRemote("git@diffinery.com:acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" });
27+ expect(parseRemote("https://github.com/acme/widgets.git", "diffinery.com")).toBeNull();
28+ });
29+});
30+
31+describe("pull requests", () => {
32+ let srv: FakeServer;
33+ let env: NodeJS.ProcessEnv;
34+ beforeEach(async () => {
35+ srv = await fakeServer();
36+ env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" });
37+ });
38+ afterEach(() => srv.close());
39+
40+ it("pr create infers the repo from origin and the head from the current branch", async () => {
41+ srv.on("POST", "/api/v3/repos/acme/widgets/pulls", { status: 201, body: pull(7) });
42+ const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x");
43+ const r = await run(["pr", "create", "--title", "Add thing", "-b", "Body text", "--base", "main", "--draft", "--intent", "Make widgets faster"], { env, cwd });
44+ expect(r.stderr).toBe("");
45+ expect(r.code).toBe(0);
46+ expect(r.stdout).toContain("#7");
47+ const req = srv.find("POST", "/api/v3/repos/acme/widgets/pulls")[0]!;
48+ expect(req.headers.authorization).toBe("Bearer dfy_t");
49+ expect(req.body).toEqual({ title: "Add thing", body: "Body text", head: "feature/x", base: "main", draft: true, intent: "Make widgets faster" });
50+ });
51+
52+ it("pr create with -R and --head omits unset fields", async () => {
53+ srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: pull(1) });
54+ const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic"], { env });
55+ expect(r.code).toBe(0);
56+ expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic" });
57+ });
58+
59+ it("pr create requires a title", async () => {
60+ const r = await run(["pr", "create", "-R", "o/r"], { env });
61+ expect(r.code).toBe(2);
62+ expect(r.stderr).toContain("--title is required");
63+ });
64+
65+ it("pr merge sends the method and delete_branch", async () => {
66+ srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } });
67+ const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env });
68+ expect(r.code).toBe(0);
69+ expect(r.stdout).toContain("Merged #12");
70+ expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true });
71+ });
72+
73+ it("pr merge defaults to a merge commit and rejects conflicting flags", async () => {
74+ srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } });
75+ expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0);
76+ expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" });
77+ const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env });
78+ expect(bad.code).toBe(2);
79+ });
80+
81+ it("surfaces the server's { message } on errors", async () => {
82+ srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } });
83+ const r = await run(["pr", "merge", "4", "-R", "o/r"], { env });
84+ expect(r.code).toBe(1);
85+ expect(r.stderr).toContain("1 approval required");
86+ });
87+
88+ it("pr review maps flags to review events", async () => {
89+ srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } });
90+ expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0);
91+ expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0);
92+ expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2);
93+ expect(srv.find("POST", "/api/v3/repos/o/r/pulls/5/reviews").map((r) => r.body)).toEqual([{ event: "APPROVE" }, { event: "REQUEST_CHANGES", body: "fix it" }]);
94+ });
95+
96+ it("pr list and --json", async () => {
97+ srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] });
98+ const r = await run(["pr", "list", "-R", "o/r"], { env });
99+ expect(r.code).toBe(0);
100+ expect(r.stdout).toMatch(/#1\s+Add thing/);
101+ expect(r.stdout).toMatch(/#2\s+WIP.*draft/);
102+ const j = await run(["pr", "list", "-R", "o/r", "--json"], { env });
103+ expect(JSON.parse(j.stdout)).toHaveLength(2);
104+ expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open");
105+ });
106+
107+ it("pr checks exits 1 on failure", async () => {
108+ srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } });
109+ const r = await run(["pr", "checks", "9", "-R", "o/r"], { env });
110+ expect(r.code).toBe(1);
111+ expect(r.stdout).toContain("ci / test");
112+ });
113+
114+ it("pr comment posts to the issue comments endpoint", async () => {
115+ srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } });
116+ expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0);
117+ expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" });
118+ });
119+});
addedapps/cli/test/runner.test.ts+94−0View file
@@ -1,0 +1,94 @@
1+import { existsSync, readFileSync, writeFileSync } from "node:fs";
2+import path from "node:path";
3+import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
5+
6+/** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */
7+function fakeAgent(exitCode = 0): { agent: string; record: string } {
8+ const dir = tempDir("diffinery-agent-");
9+ const record = path.join(dir, "record.json");
10+ const agent = path.join(dir, "agent.mjs");
11+ writeFileSync(
12+ agent,
13+ `import { writeFileSync } from "node:fs";
14+writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env }));
15+console.log("hello from the agent");
16+console.error("agent stderr line");
17+process.exit(${exitCode});
18+`,
19+ );
20+ return { agent, record };
21+}
22+
23+describe("runner", () => {
24+ let srv: FakeServer;
25+ let env: NodeJS.ProcessEnv;
26+ beforeEach(async () => {
27+ srv = await fakeServer();
28+ env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" });
29+ });
30+ afterEach(() => srv.close());
31+
32+ it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => {
33+ let claims = 0;
34+ srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () =>
35+ ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 },
36+ );
37+ const { agent, record } = fakeAgent(3);
38+ const work = tempDir("diffinery-work-");
39+ const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env });
40+
41+ expect(r.code).toBe(3);
42+ const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim");
43+ expect(claimReq).toHaveLength(1);
44+ expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] });
45+ expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token");
46+
47+ const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> };
48+ expect(rec.argv).toEqual([]);
49+ expect(rec.env.DIFFINERY_API_URL).toBe(srv.url);
50+ expect(rec.env.DIFFINERY_JOB_ID).toBe("job42");
51+ expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret");
52+ expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work);
53+ expect(rec.env.DIFFINERY_TOKEN).toBeUndefined();
54+ expect(rec.env.DIFFINERY_URL).toBeUndefined();
55+
56+ expect(r.stdout).toContain("[build (linux)] hello from the agent");
57+ expect(r.stderr).toContain("[build (linux)] agent stderr line");
58+ expect(r.stdout).not.toContain("djt_repo_job42_secret");
59+ });
60+
61+ it("--once with no waiting job exits 0 without running the agent", async () => {
62+ srv.on("POST", /\/runners\/claim$/, { status: 204 });
63+ const { agent, record } = fakeAgent(0);
64+ const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env });
65+ expect(r.code).toBe(0);
66+ expect(r.stdout).toContain("No jobs waiting");
67+ expect(existsSync(record)).toBe(false);
68+ expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]);
69+ });
70+
71+ it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => {
72+ srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 });
73+ srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } });
74+ const { agent, record } = fakeAgent(0);
75+ const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } });
76+ expect(r.code).toBe(0);
77+ expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1");
78+ });
79+
80+ it("explains how to build the agent when none is configured", async () => {
81+ const r = await run(["runner", "-R", "acme/app", "--once"], { env });
82+ expect(r.code).toBe(1);
83+ expect(r.stderr).toContain("pnpm --filter @diffinery/runner build");
84+ expect(r.stderr).toContain("containers/runner/dist/agent.mjs");
85+ });
86+
87+ it("fails fast on permission errors", async () => {
88+ srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } });
89+ const { agent } = fakeAgent(0);
90+ const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env });
91+ expect(r.code).toBe(1);
92+ expect(r.stderr).toContain("admin access required");
93+ });
94+});
addedapps/cli/test/workflow.test.ts+98−0View file
@@ -1,0 +1,98 @@
1+import { mkdirSync, writeFileSync } from "node:fs";
2+import path from "node:path";
3+import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+import { parseInputs } from "../src/commands/workflow.js";
5+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
6+
7+const GOOD = `name: CI
8+on: [push]
9+jobs:
10+ test:
11+ runs-on: ubuntu-latest
12+ steps:
13+ - run: echo hi
14+`;
15+
16+const BAD = `name: Broken
17+on: push
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - run: echo hi
23+ bogus: true
24+ deploy:
25+ needs: missing-job
26+ runs-on: ubuntu-latest
27+ steps:
28+ - run: echo deploy
29+`;
30+
31+describe("workflow validate", () => {
32+ function project(files: Record<string, string>): string {
33+ const dir = tempDir("diffinery-wf-");
34+ for (const [p, content] of Object.entries(files)) {
35+ mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
36+ writeFileSync(path.join(dir, p), content);
37+ }
38+ return dir;
39+ }
40+
41+ it("passes valid files in both workflow directories", async () => {
42+ const cwd = project({ ".github/workflows/ci.yml": GOOD, ".diffinery/workflows/other.yaml": GOOD, ".github/workflows/README.md": "not yaml" });
43+ const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() });
44+ expect(r.code).toBe(0);
45+ expect(r.stdout).toContain(".github/workflows/ci.yml");
46+ expect(r.stdout).toContain(".diffinery/workflows/other.yaml");
47+ expect(r.stderr).toContain("2 files checked: no errors");
48+ });
49+
50+ it("reports errors with file:line and exits 1", async () => {
51+ const cwd = project({ ".github/workflows/ci.yml": GOOD, ".github/workflows/bad.yml": BAD });
52+ const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() });
53+ expect(r.code).toBe(1);
54+ expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:10(:\d+)?: error: .*missing-job/);
55+ expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:8(:\d+)?: (error|warning): .*bogus/);
56+ expect(r.stderr).toMatch(/2 files checked: \d+ errors?/);
57+ });
58+
59+ it("validates explicit files and reports YAML syntax errors", async () => {
60+ const cwd = project({ "x.yml": "jobs: [unclosed\n" });
61+ const r = await run(["workflow", "validate", "x.yml", "--json"], { cwd, env: isolatedEnv() });
62+ expect(r.code).toBe(1);
63+ const res = JSON.parse(r.stdout);
64+ expect(res[0].file).toBe("x.yml");
65+ expect(res[0].valid).toBe(false);
66+ expect(res[0].problems[0].message).toMatch(/YAML/);
67+ });
68+
69+ it("is fine with no workflow files", async () => {
70+ const r = await run(["workflow", "validate"], { cwd: project({}), env: isolatedEnv() });
71+ expect(r.code).toBe(0);
72+ expect(r.stderr).toContain("No workflow files");
73+ });
74+});
75+
76+describe("workflow run", () => {
77+ let srv: FakeServer;
78+ beforeEach(async () => {
79+ srv = await fakeServer();
80+ });
81+ afterEach(() => srv.close());
82+
83+ it("dispatches with inputs on the default branch", async () => {
84+ srv.on("GET", "/api/v3/repos/o/r", { body: { default_branch: "trunk" } });
85+ srv.on("POST", /\/actions\/workflows\/.+\/dispatches$/, { status: 201, body: { run_id: "run1", html_url: "http://x" } });
86+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy" });
87+ const r = await run(["workflow", "run", "deploy.yml", "-R", "o/r", "-f", "env=prod", "-f", "dry=true"], { env });
88+ expect(r.code).toBe(0);
89+ const req = srv.find("POST", /dispatches$/)[0]!;
90+ expect(req.path).toBe("/api/v3/repos/o/r/actions/workflows/deploy.yml/dispatches");
91+ expect(req.body).toEqual({ ref: "trunk", inputs: { env: "prod", dry: "true" } });
92+ });
93+
94+ it("parses -f pairs", () => {
95+ expect(parseInputs(["a=1", "b=x=y", "c="])).toEqual({ a: "1", b: "x=y", c: "" });
96+ expect(() => parseInputs(["nope"])).toThrow();
97+ });
98+});
modifiedapps/diffinery/src/lib/directory.ts+5−5View file
@@ -1,7 +1,7 @@
11 import type { Env } from "../env.js";
22 import type { RepoMeta, Role } from "../repo/types.js";
33 import { ROLE_LEVEL } from "../repo/types.js";
4-import { HttpError, isValidName, newId, now, randomToken, sha256 } from "./util.js";
4+import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js";
55
66 export interface Account {
77 id: string;
@@ -64,7 +64,7 @@
6464 let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
6565 for (let i = 0; i < 50; i++) {
6666 const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`;
67- if (!(await this.accountByLogin(tryLogin))) return tryLogin;
67+ if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.accountByLogin(tryLogin))) return tryLogin;
6868 }
6969 candidate = `${candidate}-${randomToken(3)}`;
7070 return candidate;
@@ -93,7 +93,7 @@
9393 }
9494
9595 async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> {
96- if (patch.login && !isValidName(patch.login)) throw new HttpError(422, "invalid login");
96+ if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login");
9797 if (patch.login) {
9898 const clash = await this.accountByLogin(patch.login);
9999 if (clash && clash.id !== id) throw new HttpError(422, "login already taken");
@@ -107,7 +107,7 @@
107107 }
108108
109109 async createOrg(login: string, name: string, creator: Account): Promise<Account> {
110- if (!isValidName(login)) throw new HttpError(422, "invalid organization name");
110+ if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name");
111111 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
112112 const id = newId();
113113 const t = now();
@@ -156,7 +156,7 @@
156156 // ---------------------------------------------------------------- agents
157157
158158 async createAgent(owner: Account, input: { login: string; name?: string; orgId?: string; policy?: Record<string, unknown> }): Promise<Account> {
159- if (!isValidName(input.login)) throw new HttpError(422, "invalid agent name");
159+ if (!isValidLogin(input.login)) throw new HttpError(422, "invalid or reserved agent name");
160160 const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`;
161161 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
162162 const id = newId();
modifiedapps/diffinery/src/lib/util.ts+5−0View file
@@ -66,6 +66,11 @@
6666 }
6767 }
6868
69+/** Top-level paths the app serves; no account may use them as its login. */
70+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery"]);
71+
72+export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase());
73+
6974 export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== "..";
7075
7176 export function isValidRefName(ref: string): boolean {
modifiedapps/diffinery/src/web/actions.tsx+1−2View file
@@ -2,7 +2,6 @@
22 import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml";
33 import { raw } from "hono/html";
44 import type { FC } from "hono/jsx";
5-import type { AppContext } from "../auth/principal.js";
65 import { loadRepo } from "../http/common.js";
76 import { HttpError } from "../lib/util.js";
87 import type { JobInfo, RunInfo } from "../repo/actions.js";
@@ -431,7 +430,7 @@
431430 const failedStep = job.failure?.step;
432431 return render(
433432 c,
434- { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id},run:${run.id}` : undefined, liveMode: "log", lastSeq, wide: true },
433+ { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true },
435434 <div class="grid left">
436435 <aside class="side">
437436 <section>
modifiedapps/diffinery/src/web/assets.ts+4−5View file
@@ -276,15 +276,15 @@
276276 const live=b.dataset.live,mode=b.dataset.liveMode||'reload';
277277 if(live&&repo&&'WebSocket' in window){let ws,tries=0,timer=null,lastSeq=Number(b.dataset.lastSeq||'-1'),opened=false;
278278 function connect(){ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/'+repo+'/live?topics='+encodeURIComponent(live));
279- ws.onopen=()=>{if(opened)refresh();opened=true;tries=0};
279+ ws.onopen=()=>{if(opened){if(mode==='log')location.reload();else refresh()}opened=true;tries=0};
280280 ws.onmessage=e=>{if(e.data==='pong')return;let m;try{m=JSON.parse(e.data)}catch(_){return}handle(m)};
281281 ws.onclose=()=>{setTimeout(connect,Math.min(30000,1000*Math.pow(2,tries++)))}}
282282 setInterval(()=>{if(ws&&ws.readyState===1)ws.send('ping')},25000);
283283 function handle(m){const a=m.data&&m.data.action;
284- if(mode==='log'&&String(m.topic).startsWith('job:')){
284+ if(mode==='log'){if(!String(m.topic).startsWith('job:'))return;
285285 if(a==='logs'){append(m.data.lines||[]);return}
286286 if(a==='step'&&m.data.step){stepState(m.data.step);return}
287- if(a==='completed'){setTimeout(()=>location.reload(),700);return}}
287+ if(a==='completed'){setTimeout(()=>location.reload(),700)}return}
288288 clearTimeout(timer);timer=setTimeout(refresh,350)}
289289 async function refresh(){const frags=$$('[data-fragment]');if(!frags.length){location.reload();return}
290290 try{const r=await fetch(location.href,{credentials:'same-origin'});if(!r.ok)return;const doc=new DOMParser().parseFromString(await r.text(),'text/html');
@@ -298,8 +298,7 @@
298298 if(t.startsWith('::group::')){sp.className='grp';sp.textContent='\\u25be '+t.slice(9)}else if(/^##\\[error\\]|^::error/.test(t)){sp.className='err';sp.textContent=t}else if(/^::warning/.test(t)){sp.className='wrn';sp.textContent=t}else sp.textContent=t;
299299 pre.append(sp);const det=pre.parentElement;if(det&&!det.open)det.open=true}
300300 if(follow)scrollTo(0,d.body.scrollHeight)}
301- function stepState(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec){section(s.index);return stepState2(s)}stepState2(s)}
302- function stepState2(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm);
301+ function stepState(s){if(!$('.log [data-step="'+s.index+'"]'))section(s.index);const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm);
303302 if(s.status==='completed'&&icon){icon.className='st '+(s.conclusion==='success'?'ok':s.conclusion==='failure'?'bad':'faint');icon.textContent=s.conclusion==='success'?'\\u2713':s.conclusion==='failure'?'\\u2715':'\\u2212'}
304303 if(s.name){const label=[...sm.childNodes].find(n=>n.nodeType===3);if(label)label.textContent=s.name}}
305304 connect()}
modifiedapps/diffinery/src/web/markdown.ts+4−3View file
@@ -513,9 +513,9 @@
513513 // Paragraph.
514514 const para: string[] = [line.trimStart()];
515515 i++;
516- while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!, lines[i + 1])) {
517- // Setext headings.
518- if (/^ {0,3}=+\s*$/.test(lines[i]!) || (/^ {0,3}-+\s*$/.test(lines[i]!) && para.length === 1)) {
516+ while (i < lines.length && lines[i]!.trim()) {
517+ // Setext headings (checked before "---" is taken as a thematic break).
518+ if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) {
519519 const level = lines[i]!.includes("=") ? 1 : 2;
520520 const text = para.join(" ");
521521 const slug = slugify(text);
@@ -524,6 +524,7 @@
524524 i++;
525525 break;
526526 }
527+ if (startsBlock(lines[i]!, lines[i + 1])) break;
527528 para.push(lines[i]!.trimStart());
528529 i++;
529530 }
modifiedapps/diffinery/src/web/routes.tsx+5−0View file
@@ -7,6 +7,7 @@
77 import { registerCode } from "./code.js";
88 import { registerCollab } from "./collab.js";
99 import { registerRepoSettings } from "./repo-settings.js";
10+import { ICONS } from "./assets.js";
1011 import { errorPage } from "./ui.js";
1112
1213 /**
@@ -21,6 +22,10 @@
2122
2223 web.onError((err, c) => errorPage(c, err));
2324
25+// Browsers and crawlers ask for these on every origin; keep them off the /:owner route.
26+web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#5e6ad2"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }));
27+web.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /\n", 200, { "cache-control": "public, max-age=86400" }));
28+
2429 // Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be
2530 // registered before the /:owner/:repo patterns they would otherwise match.
2631 registerAccount(web);
modifiedapps/diffinery/test/e2e/e2e.test.mjs+17−5View file
@@ -92,11 +92,12 @@
9292 const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
9393 return r.status === 200 ? r.json : null;
9494 });
95- const r = spawnSync(process.execPath, [AGENT], {
96- encoding: "utf8",
97- env: { ...process.env, DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) },
98- timeout: 120_000,
99- });
95+ const jobEnv = { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token };
96+ // E2E_RUNNER_IMAGE runs the job inside the real runner image instead of the local agent bundle.
97+ const image = process.env.E2E_RUNNER_IMAGE;
98+ const r = image
99+ ? spawnSync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], { encoding: "utf8", timeout: 300_000 })
100+ : spawnSync(process.execPath, [AGENT], { encoding: "utf8", env: { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, timeout: 120_000 });
100101 const out = `${r.stdout}${r.stderr}`;
101102 if (r.status !== 0) {
102103 const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
@@ -314,6 +315,17 @@
314315 assert.equal(opened.isError, false, JSON.stringify(opened));
315316 assert.equal(opened.structuredContent.number, 2);
316317
318+ // ------------------------------------------------------------ web UI renders the same data (signed in via DEV_AUTH)
319+ for (const path of ["/", `/${owner}`, `/${owner}/demo`, `/${owner}/demo/blob/main/app.txt`, `/${owner}/demo/commits/main`, `/${owner}/demo/pulls`, `/${owner}/demo/pull/1`, `/${owner}/demo/pull/1/files`, `/${owner}/demo/actions`, `/${owner}/demo/actions/runs/${pushRun.id}`, `/${owner}/demo/actions/runs/${pushRun.id}/job/${job1.claim.job_id}`, `/${owner}/demo/settings/branches`, "/settings/tokens"]) {
320+ const res = await fetch(`${base}${path}`);
321+ const html = await res.text();
322+ assert.equal(res.status, 200, `${path} -> ${res.status}\n${html.slice(0, 500)}`);
323+ assert.match(res.headers.get("content-type") ?? "", /text\/html/, path);
324+ }
325+ assert.match(await (await fetch(`${base}/${owner}/demo/pull/1`)).text(), /Add a fourth line/);
326+ const forged = await fetch(`${base}/${owner}/demo/issues/1/comment`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "body=hi" });
327+ assert.equal(forged.status, 403);
328+
317329 // The commit made through MCP carries the agent's identity.
318330 const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`);
319331 assert.match(JSON.stringify(agentCommit), /e2e-bot/);
modifiedcontainers/runner/Dockerfile+2−1View file
@@ -2,7 +2,8 @@
22 # Build context: containers/runner. Run `pnpm build` first so dist/agent.mjs exists.
33 #
44 # docker build --platform linux/amd64 -t diffinery-runner containers/runner
5-FROM --platform=linux/amd64 ubuntu:24.04
5+ARG BASE_IMAGE=ubuntu:24.04
6+FROM ${BASE_IMAGE}
67
78 ARG NODE_VERSION=22.22.0
89