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(names: string[]): Promise<{122 bucketDir: string;123 saves: Record<string, string>;124 125 koh(repo: string, ...args: string[]): string;126}> {127 const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));128 const home = join(root, "home");129 const bucketDir = join(root, "bucket");130 await mkdir(home);131 const saves: Record<string, string> = {};132 for (const [i, name] of names.entries()) {133 const repoDir = join(root, "work", name);134 await mkdir(repoDir, { recursive: true });135 run(repoDir, home, ["init"]);136 if (i === 0) run(repoDir, home, ["remote", "folder", bucketDir]);137 await writeFile(join(repoDir, "README.md"), `# ${name}\n\nThe ${name} project.\n`);138 run(repoDir, home, ["save", `release ${name}`]);139 saves[name] = JSON.parse(run(repoDir, home, ["log", "--json"])).head_id;140 }141 return {142 bucketDir,143 saves,144 koh: (repo, ...args) => run(join(root, "work", repo), home, args),145 };146}147148149export type FixtureFile = { platform: string; name: string; bytes: Uint8Array } | ReleaseFile;150151export type FixtureRelease = {152 version: string;153 save: string;154 notes?: string;155 publishedAt?: number;156 files: FixtureFile[];157};158159160161162163export async function writeRelease(164 bucketDir: string,165 repo: string,166 release: FixtureRelease,167 latest = true,168): Promise<void> {169 const dir = join(bucketDir, repo, "releases", release.version);170 const files: ReleaseFile[] = [];171 for (const file of release.files) {172 const bytes = "bytes" in file ? file.bytes : new TextEncoder().encode(`stand-in ${file.name}`);173 await mkdir(join(dir, file.platform), { recursive: true });174 await writeFile(join(dir, file.platform, file.name), bytes);175 files.push(176 "bytes" in file177 ? {178 platform: file.platform,179 name: file.name,180 size: bytes.length,181 blake3: bytesToHex(blake3(bytes)),182 sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),183 }184 : file,185 );186 }187 const record = {188 format: 1,189 repo,190 version: release.version,191 save: release.save,192 published_at: release.publishedAt ?? 1790000000,193 machine: "fixture",194 notes: release.notes ?? "",195 files,196 };197 await writeFile(join(dir, "release.json"), JSON.stringify(record, null, 2));198 if (latest) await writeFile(join(bucketDir, repo, "releases", "latest"), release.version);199}