Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
24 changes: 20 additions & 4 deletions skills/opencode-drive/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }) {
Expand All @@ -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
Expand Down Expand Up @@ -251,4 +268,3 @@ opencode-drive stop --name demo
```bash
opencode-drive dir --name demo
```

20 changes: 19 additions & 1 deletion src/cli/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
1 change: 1 addition & 0 deletions src/cli/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
24 changes: 20 additions & 4 deletions src/instance/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +25,7 @@ export interface LaunchOptions {
readonly record?: boolean
readonly viewport?: UiViewport
readonly env?: Readonly<Record<string, string>>
readonly project?: ScriptProject
readonly setup?: ScriptSetup
readonly log?: (message: string) => void
}
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/script/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions src/script/project.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
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<Record<string, string | undefined>>,
) {
return Object.fromEntries(
Object.entries(env).filter(
(entry): entry is [string, string] =>
entry[1] !== undefined && !entry[0].startsWith("GIT_"),
),
)
}
11 changes: 11 additions & 0 deletions src/script/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | Uint8Array>>
}
}

export interface ScriptClients {
/** Launches a headless TUI connected to this script's shared service. */
launch(name: string, options?: ScriptClientOptions): Promise<ScriptUi>
Expand Down Expand Up @@ -281,6 +288,8 @@ export type ManualScriptRun = (
) => void | Promise<void>

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. */
Expand All @@ -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. */
Expand Down
4 changes: 4 additions & 0 deletions test/cli/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)
Expand Down
10 changes: 8 additions & 2 deletions test/fixtures/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
8 changes: 8 additions & 0 deletions test/script-filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading