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";789101112const PINNED_KOH = (await Bun.file(join(import.meta.dir, "koh-version.txt")).text()).trim();13const RUNNING_KOH = (() => {14 try {15 return Bun.spawnSync(["koh", "--version"]).stdout.toString().trim();16 } catch {17 return "";18 }19})();20if (RUNNING_KOH !== `koh ${PINNED_KOH}`) {21 throw new Error(22 `these tests are pinned to koh ${PINNED_KOH}, but koh on PATH reports "${RUNNING_KOH}" — ` +23 "install the matching koh (see koh's AGENTS.md), or update tests/koh-version.txt " +24 "once you have verified the new one",25 );26}2728export type Fixture = {29 bucketDir: string;30 repo: string;31 repoDir: string;32 33 saveIds: string[];34 35 strip(path: string): Promise<void>;36};373839const PNG = Uint8Array.from(40 atob(41 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",42 ),43 (c) => c.charCodeAt(0),44);4546const README = `<!-- a leading comment and a centred title are not the intro -->4748<p align="center">DEMO</p>4950# Demo5152A small repo for **Kepr's** tests.5354See [the guide](docs/guide.md), [the site](https://example.com) and [a trap](javascript:alert(1)).55565758<script>alert("raw html")</script>59`;60616263646566export async function makeKohBucket(repo = "demo"): Promise<Fixture> {67 const root = await mkdtemp(join(tmpdir(), "kepr-fixture-"));68 const home = join(root, "home");69 const bucketDir = join(root, "bucket");70 const repoDir = join(root, "work", repo);71 await mkdir(home);72 await mkdir(join(repoDir, "src/lib/deep"), { recursive: true });73 await mkdir(join(repoDir, "docs"));74 await mkdir(join(repoDir, "vendor"));75 const koh = (...args: string[]) => run(repoDir, home, args);7677 koh("init");78 koh("remote", "folder", bucketDir);79 await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me.\n");80 await writeFile(join(repoDir, "docs/logo.png"), PNG);81 await writeFile(join(repoDir, "secret.txt"), "hunter2\n");82 await writeFile(83 join(repoDir, "src/lib/deep/util.ts"),84 "// The *one* helper. Deep down.\nexport const one = 1;\n",85 );86 await writeFile(join(repoDir, "vendor/big.c"), "int x;\n".repeat(100));87 koh("save", "add docs, a secret and a deep helper");88 const first = JSON.parse(koh("view", "--json")).head_id as string;89 koh("flag", first, "--reason", "holds a secret");90 91 Bun.sleepSync(1100);92 await writeFile(join(repoDir, "README.md"), README);93 await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hi');\n");94 koh("save", "add readme and entry point", "--agent", "test-agent", "--session", "fixture");95 96 await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me. It is short.\n");97 koh("checkpoint", "before the second change");98 await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hello');\n");99 koh("save", "say hello");100 koh("offer");101102 const log = JSON.parse(koh("view", "--json")) as { saves: { id: string }[] };103 return {104 bucketDir,105 repo,106 repoDir,107 saveIds: log.saves.map((save) => save.id),108 strip: (path) => stripPath(repoDir, home, path),109 };110}111112function run(cwd: string, home: string, args: string[]): string {113 const result = Bun.spawnSync(["koh", ...args], { cwd, env: env(home) });114 if (result.exitCode !== 0) {115 throw new Error(116 `koh ${args.join(" ")} failed: ${result.stderr.toString()}${result.stdout.toString()}`,117 );118 }119 return result.stdout.toString();120}121122function env(home: string) {123 return { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" };124}125126127export async function stripPath(cwd: string, home: string, path: string): Promise<void> {128 let output = "";129 const proc = Bun.spawn(["koh", "strip", path], {130 cwd,131 env: env(home),132 terminal: { cols: 120, rows: 24, data: (_term, data) => (output += data.toString()) },133 });134 proc.terminal?.write(`y\n${path}\n`);135 const code = await proc.exited;136 proc.terminal?.close();137 if (code !== 0) throw new Error(`koh strip ${path} failed: ${output}`);138}139140141export async function makeRepos(names: string[]): Promise<{142 bucketDir: string;143 saves: Record<string, string>;144 145 koh(repo: string, ...args: string[]): string;146}> {147 const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));148 const home = join(root, "home");149 const bucketDir = join(root, "bucket");150 await mkdir(home);151 const saves: Record<string, string> = {};152 for (const [i, name] of names.entries()) {153 const repoDir = join(root, "work", name);154 await mkdir(repoDir, { recursive: true });155 run(repoDir, home, ["init"]);156 if (i === 0) run(repoDir, home, ["remote", "folder", bucketDir]);157 await writeFile(join(repoDir, "README.md"), `# ${name}\n\nThe ${name} project.\n`);158 run(repoDir, home, ["save", `release ${name}`]);159 saves[name] = JSON.parse(run(repoDir, home, ["view", "--json"])).head_id;160 }161 return {162 bucketDir,163 saves,164 koh: (repo, ...args) => run(join(root, "work", repo), home, args),165 };166}167168169export async function kohWorkspace(repo = "shipped"): Promise<{170 root: string;171 home: string;172 repoDir: string;173 bucketDir: string;174 run(...args: string[]): string;175}> {176 const root = await mkdtemp(join(tmpdir(), "kepr-workspace-"));177 const home = join(root, "home");178 const repoDir = join(root, "work", repo);179 const bucketDir = join(root, "bucket");180 await mkdir(home);181 await mkdir(repoDir, { recursive: true });182 return { root, home, repoDir, bucketDir, run: (...args) => run(repoDir, home, args) };183}184185186export type FixtureFile = { platform: string; name: string; bytes: Uint8Array } | ReleaseFile;187188export type FixtureRelease = {189 version: string;190 save: string;191 notes?: string;192 publishedAt?: number;193 files: FixtureFile[];194};195196197198199200201202export async function writeRelease(203 bucketDir: string,204 repo: string,205 release: FixtureRelease,206 latest = true,207): Promise<void> {208 const dir = join(bucketDir, repo, "releases", release.version);209 const files: ReleaseFile[] = [];210 for (const file of release.files) {211 const bytes = "bytes" in file ? file.bytes : new TextEncoder().encode(`stand-in ${file.name}`);212 await mkdir(join(dir, file.platform), { recursive: true });213 await writeFile(join(dir, file.platform, file.name), bytes);214 files.push(215 "bytes" in file216 ? {217 platform: file.platform,218 name: file.name,219 size: bytes.length,220 blake3: bytesToHex(blake3(bytes)),221 sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),222 }223 : file,224 );225 }226 const record = {227 format: 1,228 repo,229 version: release.version,230 save: release.save,231 published_at: release.publishedAt ?? 1790000000,232 machine: "fixture",233 notes: release.notes ?? "",234 files,235 };236 await writeFile(join(dir, "release.json"), JSON.stringify(record, null, 2));237 if (latest) await writeFile(join(bucketDir, repo, "releases", "latest"), release.version);238}239240241export function platform(): string {242 const os = process.platform === "darwin" ? "macos" : "linux";243 const arch = process.arch === "arm64" ? "aarch64" : "x86_64";244 return `${os}-${arch}`;245}