diff --git a/README.md b/README.md index ab92e19..edc3a3c 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,15 @@ Scripted runs use one fully typed definition: import { defineScript } from "opencode-drive" export default defineScript({ - async setup({ fs, config }) { + project: { + git: { + files: { + "src/example.ts": "export const value = 1\n", + }, + }, + }, + setup({ config }) { config.autoupdate = false - await fs.writeFile("src/example.ts", "export const value = 1\n") }, async run({ ui, llm }) { await ui.submit("Read src/example.ts") @@ -85,6 +91,11 @@ export default defineScript({ }) ``` +`project.git.files` creates an isolated Git repository and commits its initial +files before OpenCode starts. Files written in `setup` are included in the same +baseline commit. The project remains in the run artifacts when a script fails +so it can be inspected before pruning. + Type-check every new or edited script before running it: ```sh diff --git a/skills/opencode-drive/SKILL.md b/skills/opencode-drive/SKILL.md index 6633526..8c37aca 100644 --- a/skills/opencode-drive/SKILL.md +++ b/skills/opencode-drive/SKILL.md @@ -48,7 +48,8 @@ It will output information about the run, including paths to log files which you to inspect what happened. If you need to dig into failures that aren't clear, read those log files. If the script is unsuccessful, automatically fix the script and run it again. -Scripts use one typed definition object. `setup` runs before OpenCode starts, +Scripts use one typed definition object. `project.git.files` creates an isolated +Git repository with a committed baseline. `setup` runs before OpenCode starts, and `fs.writeFile` always writes inside the simulated project. You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts @@ -57,9 +58,15 @@ You can read the full typed API here: https://raw.githubusercontent.com/jlongste import { defineScript } from "opencode-drive" export default defineScript({ - async setup({ fs, config }) { + project: { + git: { + files: { + "src/example.ts": "export const value = 1\n", + }, + }, + }, + setup({ config }) { config.autoupdate = false - await fs.writeFile("src/example.ts", "export const value = 1\n") }, async run({ ui, llm }) { @@ -70,6 +77,16 @@ export default defineScript({ }) ``` +Files written during `setup` are included in the same baseline commit. Enable +OpenCode snapshots in `setup` when a flow needs to exercise `/undo` file +restoration: + +```ts +setup({ config }) { + config.snapshots = true +} +``` + `setup` receives the current OpenCode config object, which starts from the default drive config unless the prepared instance already has one. When a script needs custom config, mutate this `config` parameter instead of generating and @@ -251,4 +268,3 @@ opencode-drive stop --name demo ```bash opencode-drive dir --name demo ``` - diff --git a/src/cli/script.ts b/src/cli/script.ts index 76e830c..9768b6d 100644 --- a/src/cli/script.ts +++ b/src/cli/script.ts @@ -743,10 +743,28 @@ function isTimeoutError(error: unknown) { function isScriptDefinition(value: unknown): value is ScriptDefinition { if (typeof value !== "object" || value === null || Array.isArray(value)) return false - const script = value as { readonly run?: unknown; readonly setup?: unknown } + const script = value as { + readonly project?: unknown + readonly run?: unknown + readonly setup?: unknown + } return ( typeof script.run === "function" && + (script.project === undefined || isScriptProject(script.project)) && (script.setup === undefined || typeof script.setup === "function") && (!("launch" in script) || script.launch === "manual") ) } + +function isScriptProject(value: unknown) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false + const git = "git" in value ? value.git : undefined + if (typeof git !== "object" || git === null || Array.isArray(git)) return false + const files = "files" in git ? git.files : undefined + if (typeof files !== "object" || files === null || Array.isArray(files)) + return false + return Object.values(files).every( + (contents) => typeof contents === "string" || contents instanceof Uint8Array, + ) +} diff --git a/src/cli/start.ts b/src/cli/start.ts index 201be53..ab82d58 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -62,6 +62,7 @@ export async function start(options: StartOptions) { visible: options.visible, record: options.record, viewport: script?.viewport, + project: script?.project, setup: script?.setup, log: logSuccess, }).catch(async (error) => { diff --git a/src/instance/instance.ts b/src/instance/instance.ts index d9dec28..40e1a93 100644 --- a/src/instance/instance.ts +++ b/src/instance/instance.ts @@ -3,7 +3,17 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { ensureMediaDirectory } from "./media.js" import { createScriptFileSystem } from "../script/filesystem.js" -import type { JsonObject, ScriptSetup, UiViewport } from "../script/types.js" +import { + commitScriptProject, + initializeScriptProject, + stripGitEnvironment, +} from "../script/project.js" +import type { + JsonObject, + ScriptProject, + ScriptSetup, + UiViewport, +} from "../script/types.js" export interface LaunchOptions { readonly artifacts: string @@ -15,6 +25,7 @@ export interface LaunchOptions { readonly record?: boolean readonly viewport?: UiViewport readonly env?: Readonly> + readonly project?: ScriptProject readonly setup?: ScriptSetup readonly log?: (message: string) => void } @@ -84,16 +95,21 @@ export async function launchInstance(options: LaunchOptions) { 2, )}\n`, ) + if (options.project) await initializeScriptProject(files, options.project) if (options.setup) { const configFile = Bun.file(configPath) const config: JsonObject = await (await configFile.exists() ? configFile : Bun.file(new URL("./default-config.jsonc", import.meta.url)) ).json() - await options.setup({ fs: createScriptFileSystem(files), config }) + await options.setup({ + fs: createScriptFileSystem(files, { git: options.project !== undefined }), + config, + }) await Bun.write(configPath, `${JSON.stringify(config, undefined, 2)}\n`) } - const environment = cleanEnv({ + if (options.project) await commitScriptProject(files) + const environment = cleanEnv(stripGitEnvironment({ ...process.env, ...options.env, OPENCODE_SIMULATE: "1", @@ -109,7 +125,7 @@ export async function launchInstance(options: LaunchOptions) { XDG_CONFIG_HOME: join(artifacts, "home", ".config"), XDG_DATA_HOME: logs, XDG_STATE_HOME: join(artifacts, "home", ".local", "state"), - }) + })) const command = options.dev ? await prepareDev(artifacts, options.dev) : options.command?.length diff --git a/src/script/filesystem.ts b/src/script/filesystem.ts index dffc36f..94a65d8 100644 --- a/src/script/filesystem.ts +++ b/src/script/filesystem.ts @@ -2,7 +2,10 @@ import { lstat, mkdir, writeFile } from "node:fs/promises" import { dirname, isAbsolute, relative, resolve, sep } from "node:path" import type { ScriptFileSystem } from "./types.js" -export function createScriptFileSystem(directory: string): ScriptFileSystem { +export function createScriptFileSystem( + directory: string, + options: { readonly git?: boolean } = {}, +): ScriptFileSystem { const root = resolve(directory) return { async writeFile(path, contents) { @@ -11,6 +14,8 @@ export function createScriptFileSystem(directory: string): ScriptFileSystem { const resolved = relative(root, destination) if (resolved === ".." || resolved.startsWith(`..${sep}`)) throw new Error("fs.writeFile path must stay inside the simulated project") + if (options.git && resolved.split(sep)[0] === ".git") + throw new Error("fs.writeFile path must not modify Git metadata") await rejectSymlinks(root, destination) await mkdir(dirname(destination), { recursive: true }) await writeFile(destination, contents) diff --git a/src/script/project.ts b/src/script/project.ts new file mode 100644 index 0000000..42c21dd --- /dev/null +++ b/src/script/project.ts @@ -0,0 +1,63 @@ +import type { ScriptProject } from "./types.js" +import { createScriptFileSystem } from "./filesystem.js" +import { rm } from "node:fs/promises" +import { join } from "node:path" + +export async function initializeScriptProject( + root: string, + project: ScriptProject, +) { + const fs = createScriptFileSystem(root, { git: true }) + await Promise.all( + Object.entries(project.git.files).map(([path, contents]) => + fs.writeFile(path, contents), + ), + ) +} + +export async function commitScriptProject(root: string) { + await rm(join(root, ".git"), { recursive: true, force: true }) + await git(root, ["init", "--quiet", "--initial-branch=main"]) + await git(root, ["add", "--force", "--all"]) + await git(root, [ + "-c", + "user.name=OpenCode Drive", + "-c", + "user.email=drive@opencode.ai", + "commit", + "--quiet", + "--message=Initial commit", + ]) +} + +async function git(cwd: string, args: ReadonlyArray) { + const process = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...stripGitEnvironment(Bun.env), + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z", + }, + }) + const [status, stderr] = await Promise.all([ + process.exited, + new Response(process.stderr).text(), + ]) + if (status === 0) return + throw new Error(`git ${args[0]} failed: ${stderr.trim()}`) +} + +export function stripGitEnvironment( + env: Readonly>, +) { + return Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => + entry[1] !== undefined && !entry[0].startsWith("GIT_"), + ), + ) +} diff --git a/src/script/types.ts b/src/script/types.ts index ee67916..bae5e70 100644 --- a/src/script/types.ts +++ b/src/script/types.ts @@ -238,6 +238,13 @@ export interface ScriptSetupContext { readonly config: JsonObject } +export interface ScriptProject { + /** Creates an isolated Git repository and commits its initial files before OpenCode starts. */ + readonly git: { + readonly files: Readonly> + } +} + export interface ScriptClients { /** Launches a headless TUI connected to this script's shared service. */ launch(name: string, options?: ScriptClientOptions): Promise @@ -281,6 +288,8 @@ export type ManualScriptRun = ( ) => void | Promise export interface AutomaticScriptDefinition { + /** Declares the isolated project OpenCode runs against. */ + readonly project?: ScriptProject /** Runs once before OpenCode starts. */ readonly setup?: ScriptSetup /** Initial terminal viewport for the default client. */ @@ -292,6 +301,8 @@ export interface AutomaticScriptDefinition { export interface ManualScriptDefinition { /** The server and every client are launched explicitly by the script. */ readonly launch: "manual" + /** Declares the isolated project OpenCode runs against. */ + readonly project?: ScriptProject /** Runs once before OpenCode starts. */ readonly setup?: ScriptSetup /** Initial terminal viewport for clients that do not specify one. */ diff --git a/test/cli/integration.test.ts b/test/cli/integration.test.ts index 0279ddc..f4906bd 100644 --- a/test/cli/integration.test.ts +++ b/test/cli/integration.test.ts @@ -715,6 +715,10 @@ describe("opencode-drive", () => { expect(await Bun.file(join(artifacts, "seeded-at-launch.txt")).text()).toBe( "export const seeded = true\n", ) + expect(await Bun.$`git status --porcelain`.cwd(join(artifacts, "files")).text()).toBe("") + expect( + (await Bun.$`git log -1 --format=%s`.cwd(join(artifacts, "files")).text()).trim(), + ).toBe("Initial commit") expect(await realpath(await Bun.file(join(artifacts, "child-cwd.txt")).text())).toBe( await realpath(join(artifacts, "files")), ) diff --git a/test/fixtures/script.ts b/test/fixtures/script.ts index 0e15525..74e4d0f 100644 --- a/test/fixtures/script.ts +++ b/test/fixtures/script.ts @@ -2,9 +2,15 @@ import { join } from "node:path" import { defineScript } from "../../src/index.js" export default defineScript({ - async setup({ fs, config }) { + project: { + git: { + files: { + "src/seeded.ts": "export const seeded = true\n", + }, + }, + }, + setup({ config }) { config.autoupdate = false - await fs.writeFile("src/seeded.ts", "export const seeded = true\n") }, async run({ artifacts, llm, ui }) { diff --git a/test/script-filesystem.test.ts b/test/script-filesystem.test.ts index 7d42234..1c4c1f2 100644 --- a/test/script-filesystem.test.ts +++ b/test/script-filesystem.test.ts @@ -38,6 +38,14 @@ describe("script filesystem", () => { "must not contain symbolic links", ) }) + + test("reserves Git metadata for declared Git projects", async () => { + const root = await temporary() + const fs = createScriptFileSystem(root, { git: true }) + await expect(fs.writeFile(".git/config", "no")).rejects.toThrow( + "must not modify Git metadata", + ) + }) }) async function temporary() { diff --git a/test/script-project.test.ts b/test/script-project.test.ts new file mode 100644 index 0000000..3c9a24a --- /dev/null +++ b/test/script-project.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + commitScriptProject, + initializeScriptProject, +} from "../src/script/project.js" + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ) +}) + +describe("script project", () => { + test("creates a clean Git baseline containing the declared files", async () => { + const root = await temporary() + await initializeScriptProject(root, { + git: { + files: { + "src/example.ts": "export const value = 1\n", + }, + }, + }) + await commitScriptProject(root) + + expect(await Bun.file(join(root, "src/example.ts")).text()).toBe( + "export const value = 1\n", + ) + expect(await git(root, ["status", "--porcelain"])).toBe("") + expect((await git(root, ["log", "-1", "--format=%s"])).trim()).toBe( + "Initial commit", + ) + }) + + test("rejects files outside the project", async () => { + const root = await temporary() + await expect( + initializeScriptProject(root, { + git: { files: { "../outside.ts": "no" } }, + }), + ).rejects.toThrow("stay inside") + }) + + test("reserves Git metadata", async () => { + const root = await temporary() + await expect( + initializeScriptProject(root, { + git: { files: { ".git/config": "[core]\nworktree = /tmp\n" } }, + }), + ).rejects.toThrow("must not modify Git metadata") + }) + + test("includes ignored files and replaces prepared Git metadata", async () => { + const root = await temporary() + await initializeScriptProject(root, { + git: { + files: { + ".gitignore": "ignored.txt\n", + "ignored.txt": "tracked baseline\n", + }, + }, + }) + await mkdir(join(root, ".git")) + await Bun.write(join(root, ".git", "config"), "[core]\nworktree = /tmp\n") + await commitScriptProject(root) + + expect((await git(root, ["ls-files"])).split("\n")).toContain("ignored.txt") + expect(await git(root, ["status", "--porcelain"])).toBe("") + }) +}) + +async function temporary() { + const root = await mkdtemp(join(tmpdir(), "opencode-drive-project-test-")) + roots.push(root) + return root +} + +async function git(cwd: string, args: ReadonlyArray) { + return Bun.$`git ${args}`.cwd(cwd).text() +}