1import { mkdir, mkdtemp, writeFile } from "node:fs/promises";2import { tmpdir } from "node:os";3import { join } from "node:path";4import { blake3 } from "@noble/hashes/blake3.js";5import { bytesToHex } from "@noble/hashes/utils.js";6import type { ReleaseFile } from "../src/bucket/repo.ts";78export type Fixture = {9 bucketDir: string;10 repo: string;11 repoDir: string;12 13 saveIds: string[];14 15 strip(path: string): Promise<void>;16};171819const PNG = Uint8Array.from(20 atob(21 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",22 ),23 (c) => c.charCodeAt(0),24);2526const README = `<!-- a leading comment and a centred title are not the intro -->2728<p align="center">DEMO</p>2930# Demo3132A small repo for **Kepr's** tests.3334See [the guide](docs/guide.md), [the site](https://example.com) and [a trap](javascript:alert(1)).35363738<script>alert("raw html")</script>39`;40414243444546export async function makeKohBucket(repo = "demo"): Promise<Fixture> {47 const root = await mkdtemp(join(tmpdir(), "kepr-fixture-"));48 const home = join(root, "home");49 const bucketDir = join(root, "bucket");50 const repoDir = join(root, "work", repo);51 await mkdir(home);52 await mkdir(join(repoDir, "src/lib/deep"), { recursive: true });53 await mkdir(join(repoDir, "docs"));54 await mkdir(join(repoDir, "vendor"));55 const koh = (...args: string[]) => run(repoDir, home, args);5657 koh("init");58 koh("remote", "folder", bucketDir);59 await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me.\n");60 await writeFile(join(repoDir, "docs/logo.png"), PNG);61 await writeFile(join(repoDir, "secret.txt"), "hunter2\n");62 await writeFile(63 join(repoDir, "src/lib/deep/util.ts"),64 "// The *one* helper. Deep down.\nexport const one = 1;\n",65 );66 await writeFile(join(repoDir, "vendor/big.c"), "int x;\n".repeat(100));67 koh("save", "add docs, a secret and a deep helper");68 const first = JSON.parse(koh("log", "--json")).head_id as string;69 koh("flag", first, "--reason", "holds a secret");70 71 Bun.sleepSync(1100);72 await writeFile(join(repoDir, "README.md"), README);73 await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hi');\n");74 koh("save", "add readme and entry point", "--agent", "test-agent", "--session", "fixture");75 76 await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me. It is short.\n");77 koh("checkpoint", "before the second change");78 await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hello');\n");79 koh("save", "say hello");80 koh("offer");8182 const log = JSON.parse(koh("log", "--json")) as { saves: { id: string }[] };83 return {84 bucketDir,85 repo,86 repoDir,87 saveIds: log.saves.map((save) => save.id),88 strip: (path) => strip(repoDir, home, path),89 };90}9192function run(cwd: string, home: string, args: string[]): string {93 const result = Bun.spawnSync(["koh3", ...args], { cwd, env: env(home) });94 if (result.exitCode !== 0) {95 throw new Error(96 `koh3 ${args.join(" ")} failed: ${result.stderr.toString()}${result.stdout.toString()}`,97 );98 }99 return result.stdout.toString();100}101102function env(home: string) {103 return { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" };104}105106107async function strip(cwd: string, home: string, path: string): Promise<void> {108 let output = "";109 const proc = Bun.spawn(["koh3", "strip", path], {110 cwd,111 env: env(home),112 terminal: { cols: 120, rows: 24, data: (_term, data) => (output += data.toString()) },113 });114 proc.terminal?.write(`y\n${path}\n`);115 const code = await proc.exited;116 proc.terminal?.close();117 if (code !== 0) throw new Error(`koh3 strip ${path} failed: ${output}`);118}119120121export async function makeRepos(122 names: string[],123): Promise<{ bucketDir: string; saves: Record<string, string> }> {124 const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));125 const home = join(root, "home");126 const bucketDir = join(root, "bucket");127 await mkdir(home);128 const saves: Record<string, string> = {};129 for (const [i, name] of names.entries()) {130 const repoDir = join(root, "work", name);131 await mkdir(repoDir, { recursive: true });132 run(repoDir, home, ["init"]);133 if (i === 0) run(repoDir, home, ["remote", "folder", bucketDir]);134 await writeFile(join(repoDir, "README.md"), `# ${name}\n\nThe ${name} project.\n`);135 run(repoDir, home, ["save", `release ${name}`]);136 saves[name] = JSON.parse(run(repoDir, home, ["log", "--json"])).head_id;137 }138 return { bucketDir, saves };139}140141142export type FixtureFile = { platform: string; name: string; bytes: Uint8Array } | ReleaseFile;143144export type FixtureRelease = {145 version: string;146 save: string;147 notes?: string;148 publishedAt?: number;149 files: FixtureFile[];150};151152153154155156export async function writeRelease(157 bucketDir: string,158 repo: string,159 release: FixtureRelease,160 latest = true,161): Promise<void> {162 const dir = join(bucketDir, repo, "releases", release.version);163 const files: ReleaseFile[] = [];164 for (const file of release.files) {165 const bytes = "bytes" in file ? file.bytes : new TextEncoder().encode(`stand-in ${file.name}`);166 await mkdir(join(dir, file.platform), { recursive: true });167 await writeFile(join(dir, file.platform, file.name), bytes);168 files.push(169 "bytes" in file170 ? {171 platform: file.platform,172 name: file.name,173 size: bytes.length,174 blake3: bytesToHex(blake3(bytes)),175 sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),176 }177 : file,178 );179 }180 const record = {181 format: 1,182 repo,183 version: release.version,184 save: release.save,185 published_at: release.publishedAt ?? 1790000000,186 machine: "fixture",187 notes: release.notes ?? "",188 files,189 };190 await writeFile(join(dir, "release.json"), JSON.stringify(record, null, 2));191 if (latest) await writeFile(join(bucketDir, repo, "releases", "latest"), release.version);192}