cli: extract run from mini package (#37737)
This commit is contained in:
parent
cf6e5b3604
commit
3f5ad8441f
15 changed files with 500 additions and 50 deletions
|
|
@ -22,6 +22,7 @@
|
|||
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
||||
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
||||
"./mini/*": "./src/mini/*.ts",
|
||||
"./run": "./src/run/index.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
|
|||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini/mini"))
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
|
|||
|
||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
|
||||
const separator = process.argv.indexOf("--", 2)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
|
|
|
|||
|
|
@ -1,8 +1 @@
|
|||
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export {
|
||||
runNonInteractive,
|
||||
mergeInput as mergeNonInteractiveInput,
|
||||
pickRunModel,
|
||||
parseRunModel,
|
||||
type RunCommandInput,
|
||||
} from "./run"
|
||||
|
|
|
|||
2
packages/cli/src/run/index.ts
Normal file
2
packages/cli/src/run/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { runNonInteractive, type RunCommandInput } from "./run"
|
||||
export { runV1Bridge, type V1RunCommandInput } from "./v1"
|
||||
|
|
@ -2,9 +2,9 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
|
|||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { toolOutputText } from "./tool"
|
||||
import { toolOutputText } from "../mini/tool"
|
||||
import type { MiniToolPart } from "../mini/types"
|
||||
import { UI } from "./ui"
|
||||
import type { MiniToolPart } from "./types"
|
||||
|
||||
type Model = {
|
||||
providerID: string
|
||||
|
|
@ -30,6 +30,7 @@ type Input = {
|
|||
auto: boolean
|
||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||
attached: boolean
|
||||
compatibility?: "v1"
|
||||
renderTool: (part: MiniToolPart) => Promise<void>
|
||||
renderToolError: (part: MiniToolPart) => Promise<void>
|
||||
}
|
||||
|
|
@ -71,7 +72,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
let permissionRejected = false
|
||||
let formCancelled = false
|
||||
let interrupted = false
|
||||
let v1InvalidOutput = false
|
||||
let admission: AbortController | undefined
|
||||
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
|
||||
|
||||
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
|
||||
if (input.format !== "json") return false
|
||||
|
|
@ -92,6 +95,17 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
UI.empty()
|
||||
}
|
||||
|
||||
const flushStep = () => {
|
||||
if (!pendingStep) return
|
||||
const value = pendingStep
|
||||
pendingStep = undefined
|
||||
if (!emit("step_start", value.timestamp, { part: value.part }) && input.format !== "json") {
|
||||
UI.empty()
|
||||
UI.println(value.label)
|
||||
UI.empty()
|
||||
}
|
||||
}
|
||||
|
||||
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||
if (!input.auto) {
|
||||
permissionRejected = true
|
||||
|
|
@ -178,6 +192,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
type: "step-start",
|
||||
snapshot: event.data.snapshot,
|
||||
}
|
||||
if (input.compatibility === "v1") {
|
||||
pendingStep = {
|
||||
timestamp: time,
|
||||
part,
|
||||
label: `> ${event.data.agent} · ${event.data.model.id}`,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!emit("step_start", time, { part }) && input.format !== "json") {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
|
||||
|
|
@ -187,6 +209,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
}
|
||||
|
||||
if (event.type === "session.text.started") {
|
||||
flushStep()
|
||||
starts.set("text", { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
|
|
@ -206,6 +229,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
}
|
||||
|
||||
if (event.type === "session.reasoning.started") {
|
||||
flushStep()
|
||||
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
|
|
@ -236,6 +260,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
}
|
||||
|
||||
if (event.type === "session.tool.input.started") {
|
||||
flushStep()
|
||||
tools.set(event.data.callID, {
|
||||
id: partID(event.id),
|
||||
timestamp: time,
|
||||
|
|
@ -251,6 +276,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
flushStep()
|
||||
const current = tools.get(event.data.callID)
|
||||
tools.set(event.data.callID, {
|
||||
id: current?.id ?? partID(event.id),
|
||||
|
|
@ -316,6 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
},
|
||||
}
|
||||
tools.delete(event.data.callID)
|
||||
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
|
||||
if (!emit("tool_use", time, { part })) {
|
||||
await input.renderToolError(part)
|
||||
UI.error(error)
|
||||
|
|
@ -324,6 +351,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
}
|
||||
|
||||
if (event.type === "session.step.ended") {
|
||||
flushStep()
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
|
|
@ -338,13 +366,28 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
continue
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
if (
|
||||
input.compatibility === "v1" &&
|
||||
event.data.error.message === "Provider stream ended without a terminal finish event"
|
||||
) {
|
||||
pendingStep = undefined
|
||||
v1InvalidOutput = true
|
||||
continue
|
||||
}
|
||||
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
||||
flushStep()
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (
|
||||
input.compatibility === "v1" &&
|
||||
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
|
||||
)
|
||||
return
|
||||
flushStep()
|
||||
if (!emittedError && !questionRejected && !formCancelled) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
|
|
@ -353,6 +396,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
return
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
|
||||
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
||||
if (event.data.reason !== "user" && !emittedError) {
|
||||
emittedError = true
|
||||
|
|
@ -478,7 +522,7 @@ async function prepareFile(file: File) {
|
|||
const uri = file.url.startsWith("data:")
|
||||
? file.url
|
||||
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
|
||||
return { attachment: { uri, mime: file.mime, name: file.filename } }
|
||||
return { attachment: { uri, name: file.filename } }
|
||||
}
|
||||
const content = file.url.startsWith("data:")
|
||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||
|
|
@ -6,10 +6,10 @@ import { open } from "node:fs/promises"
|
|||
import path from "node:path"
|
||||
import { readStdin } from "../util/io"
|
||||
import { ServerConnection } from "../services/server-connection"
|
||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||
import { loadRunAgents, waitForCatalogReady } from "../mini/catalog.shared"
|
||||
import { toolInlineInfo } from "../mini/tool"
|
||||
import type { MiniToolPart } from "../mini/types"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { toolInlineInfo } from "./tool"
|
||||
import type { MiniToolPart } from "./types"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
|
|
@ -39,24 +39,39 @@ type Prepared = {
|
|||
files: FilePart[]
|
||||
}
|
||||
|
||||
type ExecutionOptions = {
|
||||
root?: string
|
||||
directory?: string
|
||||
useServerDirectory?: boolean
|
||||
variant?: string
|
||||
attached?: boolean
|
||||
compatibility?: "v1"
|
||||
}
|
||||
|
||||
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export function runNonInteractive(input: RunCommandInput) {
|
||||
return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
|
||||
return runNonInteractiveWithOptions(input, {})
|
||||
}
|
||||
|
||||
async function run(input: RunCommandInput) {
|
||||
/** @internal Used only by the V1 command boundary. */
|
||||
export function runNonInteractiveWithOptions(input: RunCommandInput, options: ExecutionOptions) {
|
||||
return run(input, options).catch((error) => reportRunError(input, errorMessage(error)))
|
||||
}
|
||||
|
||||
async function run(input: RunCommandInput, options: ExecutionOptions) {
|
||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
const directory = localDirectory(root)
|
||||
const root = options.root ?? process.env.PWD ?? process.cwd()
|
||||
const local = localDirectory(root)
|
||||
const directory = options.useServerDirectory ? undefined : (options.directory ?? local)
|
||||
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
|
||||
if (!message?.trim()) fail("You must provide a message")
|
||||
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
|
||||
const files = await Promise.all(input.file.map((file) => prepareFile(file, root, options)))
|
||||
const prepared = { directory, message, files }
|
||||
return execute(input, prepared, input.server.endpoint)
|
||||
return execute(input, prepared, input.server.endpoint, options)
|
||||
}
|
||||
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) {
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||
|
|
@ -65,7 +80,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||
const workspace = session?.location.workspaceID
|
||||
const explicit = parseRunModel(input.model)
|
||||
const explicitModel = explicit?.model
|
||||
const variant = explicit?.variant
|
||||
const variant = options.variant ?? explicit?.variant
|
||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||
const defaultModel =
|
||||
!explicitModel && !sessionModel
|
||||
|
|
@ -74,12 +89,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||
: undefined
|
||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
||||
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (model) {
|
||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
||||
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||
return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||
}
|
||||
const agent = await validateAgent(client, cwd, input.agent)
|
||||
const selected =
|
||||
|
|
@ -107,10 +122,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||
thinking: input.thinking ?? false,
|
||||
format: input.format,
|
||||
auto: input.auto ?? false,
|
||||
attached: true,
|
||||
attached: options.attached ?? true,
|
||||
compatibility: options.compatibility,
|
||||
renderTool,
|
||||
renderToolError,
|
||||
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
|
||||
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
|
||||
}
|
||||
|
||||
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
||||
|
|
@ -185,11 +201,13 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
|
|||
return client.session.fork({ sessionID: selected.id })
|
||||
}
|
||||
|
||||
async function prepareFile(input: string, directory: string): Promise<FilePart> {
|
||||
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
||||
const file = path.resolve(directory, input)
|
||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||
try {
|
||||
const stat = await handle.stat()
|
||||
if (options.compatibility === "v1" && options.attached && stat.isDirectory())
|
||||
fail(`Cannot attach local directory without a shared filesystem: ${input}`)
|
||||
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
|
||||
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
||||
const content = Buffer.alloc(Number(stat.size))
|
||||
|
|
@ -249,7 +267,15 @@ function warning(message: string) {
|
|||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||
}
|
||||
|
||||
function reportError(input: RunCommandInput, message: string, sessionID?: string) {
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||
return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** @internal Used by the V1 command boundary before a Session exists. */
|
||||
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
|
||||
process.exitCode = 1
|
||||
if (input.format === "json") {
|
||||
process.stdout.write(
|
||||
85
packages/cli/src/run/v1.ts
Normal file
85
packages/cli/src/run/v1.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { Standalone } from "../services/standalone"
|
||||
import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
|
||||
|
||||
export type V1RunCommandInput = {
|
||||
message: string[]
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
model?: string
|
||||
agent?: string
|
||||
format: "default" | "json"
|
||||
file: string[]
|
||||
title?: string
|
||||
server?: string
|
||||
password?: string
|
||||
username?: string
|
||||
directory?: string
|
||||
variant?: string
|
||||
thinking?: boolean
|
||||
dangerouslySkipPermissions?: boolean
|
||||
standaloneCommand?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export function runV1Bridge(input: V1RunCommandInput) {
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
const attached = input.server !== undefined
|
||||
const local = !attached && input.directory ? path.resolve(root, input.directory) : root
|
||||
try {
|
||||
process.chdir(local)
|
||||
} catch {
|
||||
reportRunError(input, `Failed to change directory to ${local}`)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = attached
|
||||
? explicitEndpoint(input)
|
||||
: yield* Standalone.start({ command: input.standaloneCommand })
|
||||
yield* Effect.promise(() =>
|
||||
runNonInteractiveWithOptions(nativeInput(input, endpoint), {
|
||||
root: local,
|
||||
directory: attached ? input.directory : local,
|
||||
useServerDirectory: attached && input.directory === undefined,
|
||||
variant: input.variant,
|
||||
attached,
|
||||
compatibility: "v1",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
|
||||
function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
|
||||
return {
|
||||
server: { endpoint },
|
||||
message: input.message,
|
||||
continue: input.continue,
|
||||
session: input.session,
|
||||
fork: input.fork,
|
||||
model: input.model,
|
||||
agent: input.agent,
|
||||
format: input.format,
|
||||
file: input.file,
|
||||
title: input.title,
|
||||
thinking: input.thinking,
|
||||
auto: input.dangerouslySkipPermissions,
|
||||
}
|
||||
}
|
||||
|
||||
function explicitEndpoint(input: V1RunCommandInput): Endpoint {
|
||||
const url = input.server
|
||||
if (!url) throw new Error("Missing V1 server URL")
|
||||
return {
|
||||
url,
|
||||
auth: input.password
|
||||
? { type: "basic", username: input.username ?? "opencode", password: input.password }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
87
packages/cli/test/drive/run-smoke.drive.mjs
Normal file
87
packages/cli/test/drive/run-smoke.drive.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { defineScript } from "opencode-drive"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
setup({ config }) {
|
||||
config.autoupdate = false
|
||||
},
|
||||
async run({ artifacts, llm, server }) {
|
||||
await configureServicePort(artifacts)
|
||||
llm.queue(llm.text("drive noninteractive smoke ok"))
|
||||
await server.launch()
|
||||
|
||||
const registration = await serviceRegistration(artifacts)
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const directory = path.join(artifacts, "files")
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
"drive smoke",
|
||||
],
|
||||
{
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: directory,
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
|
||||
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function configureServicePort(artifacts) {
|
||||
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const port = probe.port
|
||||
await probe.stop(true)
|
||||
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await Bun.write(file, JSON.stringify({ port }))
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function serviceRegistration(artifacts) {
|
||||
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
for (const name of ["service-local.json", "service.json"]) {
|
||||
const value = await Bun.file(path.join(directory, name))
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (isRegistration(value)) return value
|
||||
}
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Drive service registration was not written")
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function isRegistration(value) {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"url" in value &&
|
||||
typeof value.url === "string" &&
|
||||
"password" in value &&
|
||||
typeof value.password === "string"
|
||||
)
|
||||
}
|
||||
60
packages/cli/test/import-boundaries.test.ts
Normal file
60
packages/cli/test/import-boundaries.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
|
||||
describe("CLI frontend import boundaries", () => {
|
||||
test("exposes only the run entrypoints from the run package export", async () => {
|
||||
const entrypoint = await import("@opencode-ai/cli/run")
|
||||
const mini = await import("@opencode-ai/cli/mini")
|
||||
|
||||
expect(Object.keys(entrypoint).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["mergeInteractiveInput", "runMini", "validateMiniTerminal"])
|
||||
})
|
||||
|
||||
test("keeps run and Mini handlers on separate leaf graphs", async () => {
|
||||
const run = await bundleInputs("src/commands/handlers/run.ts")
|
||||
expect(run).toContain("src/run/run.ts")
|
||||
expect(run).not.toContain("src/mini/mini.ts")
|
||||
expect(run).not.toContain("src/mini/runtime.ts")
|
||||
|
||||
const mini = await bundleInputs("src/commands/handlers/mini.ts")
|
||||
expect(mini).toContain("src/mini/mini.ts")
|
||||
expect(mini).not.toContain("src/run/run.ts")
|
||||
expect(mini).not.toContain("src/run/noninteractive.ts")
|
||||
expect(mini).not.toContain("src/run/ui.ts")
|
||||
})
|
||||
})
|
||||
|
||||
async function bundleInputs(entrypoint: string) {
|
||||
const temporary = await mkdtemp(path.join(import.meta.dir, ".import-boundary-"))
|
||||
const metafile = path.join(temporary, "meta.json")
|
||||
try {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"build",
|
||||
entrypoint,
|
||||
"--target=bun",
|
||||
"--format=esm",
|
||||
"--packages=external",
|
||||
`--metafile=${metafile}`,
|
||||
`--outdir=${path.join(temporary, "out")}`,
|
||||
],
|
||||
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
const metadata = await Bun.file(metafile).json()
|
||||
return Object.keys(metadata.inputs).map((input) =>
|
||||
path.relative(root, path.resolve(root, input)).replaceAll(path.sep, "/"),
|
||||
)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "node:path"
|
||||
import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
|
||||
import { mergeInput as mergeInteractiveInput } from "../src/mini/mini"
|
||||
import { toolInlineInfo, toolOutputText, toolView } from "../src/mini/tool"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
|
|
@ -59,7 +60,7 @@ describe("mini command", () => {
|
|||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||
})
|
||||
|
||||
test("keeps run as mini's non-interactive input mode", () => {
|
||||
test("merges non-interactive argument and stdin input", () => {
|
||||
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
||||
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
||||
})
|
||||
|
|
@ -128,14 +129,7 @@ describe("mini command", () => {
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await cli([
|
||||
"run",
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
"--model",
|
||||
"definitely/missing",
|
||||
"hi",
|
||||
])
|
||||
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
||||
import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
|
||||
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||
|
||||
type V2Event = EventSubscribeOutput
|
||||
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
|
|
@ -50,9 +50,57 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
|||
}
|
||||
}
|
||||
|
||||
function stepStarted(): V2Event {
|
||||
return {
|
||||
id: "evt_step_started",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stepFailed(message: string): V2Event {
|
||||
return {
|
||||
id: "evt_step_failed",
|
||||
created: 2,
|
||||
type: "session.step.failed",
|
||||
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
error: { type: "provider.transport", message },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function executionFailed(message: string): V2Event {
|
||||
return {
|
||||
id: "evt_execution_failed",
|
||||
created: 3,
|
||||
type: "session.execution.failed",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
error: { type: "provider.transport", message },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||
// live events the prompt admission triggers, keyed by the generated message ID.
|
||||
async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
|
||||
async function run(input: {
|
||||
turn: (inputID: string) => V2Event[]
|
||||
pendingForms?: FormInfo[]
|
||||
attached?: boolean
|
||||
format?: "default" | "json"
|
||||
compatibility?: "v1"
|
||||
}) {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||
let wake: (() => void) | undefined
|
||||
|
|
@ -88,15 +136,38 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
|
|||
message: "hello",
|
||||
files: [],
|
||||
thinking: false,
|
||||
format: "default",
|
||||
format: input.format ?? "default",
|
||||
auto: false,
|
||||
attached: input.attached ?? false,
|
||||
compatibility: input.compatibility,
|
||||
renderTool: () => Promise.resolve(),
|
||||
renderToolError: () => Promise.resolve(),
|
||||
})
|
||||
return sdk
|
||||
}
|
||||
|
||||
async function capture(input: Parameters<typeof run>[0]) {
|
||||
const stdout: string[] = []
|
||||
const stderr: string[] = []
|
||||
const exitCode = process.exitCode
|
||||
const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
stdout.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||
stderr.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
try {
|
||||
await run(input)
|
||||
return { stdout: stdout.join(""), stderr: stderr.join("") }
|
||||
} finally {
|
||||
process.exitCode = exitCode ?? 0
|
||||
stdoutWrite.mockRestore()
|
||||
stderrWrite.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
|
@ -125,4 +196,58 @@ describe("runNonInteractivePrompt", () => {
|
|||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
||||
})
|
||||
|
||||
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||
const output = await capture({
|
||||
compatibility: "v1",
|
||||
format: "json",
|
||||
turn: (messageID) => [
|
||||
prompted(messageID),
|
||||
stepStarted(),
|
||||
stepFailed("Provider request failed"),
|
||||
executionFailed("Provider request failed"),
|
||||
],
|
||||
})
|
||||
|
||||
expect(
|
||||
output.stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([
|
||||
expect.objectContaining({ type: "step_start", part: expect.objectContaining({ type: "step-start" }) }),
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
error: { type: "provider.transport", message: "Provider request failed" },
|
||||
}),
|
||||
])
|
||||
expect(output.stderr).toBe("")
|
||||
})
|
||||
|
||||
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
|
||||
const output = await capture({
|
||||
compatibility: "v1",
|
||||
turn: (messageID) => [prompted(messageID), stepStarted(), executionFailed("Execution failed")],
|
||||
})
|
||||
|
||||
expect(output.stdout).toBe("")
|
||||
expect(output.stderr).toContain("> build · test-model")
|
||||
expect(output.stderr).toContain("Error: \u001b[0mExecution failed")
|
||||
expect(output.stderr.indexOf("> build · test-model")).toBeLessThan(output.stderr.indexOf("Execution failed"))
|
||||
})
|
||||
|
||||
test("V1 preserves terminal-finish failure suppression before content", async () => {
|
||||
const output = await capture({
|
||||
compatibility: "v1",
|
||||
format: "json",
|
||||
turn: (messageID) => [
|
||||
prompted(messageID),
|
||||
stepStarted(),
|
||||
stepFailed("Provider stream ended without a terminal finish event"),
|
||||
executionFailed("Provider stream ended without a terminal finish event"),
|
||||
],
|
||||
})
|
||||
|
||||
expect(output).toEqual({ stdout: "", stderr: "" })
|
||||
})
|
||||
})
|
||||
|
|
@ -99,9 +99,9 @@ export const RunCommand = effectCmd({
|
|||
default: false,
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini"))
|
||||
const { runV1Bridge } = yield* Effect.promise(() => import("@opencode-ai/cli/run"))
|
||||
yield* Effect.promise(() =>
|
||||
runNonInteractive({
|
||||
runV1Bridge({
|
||||
message: [...args.message, ...(args["--"] || [])],
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
|
|
@ -112,7 +112,6 @@ export const RunCommand = effectCmd({
|
|||
file: args.file ?? [],
|
||||
title: args.title,
|
||||
server: args.server ?? args.attach,
|
||||
// @ts-expect-error V1 does not consume the V2-only resolved server input.
|
||||
password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
|
||||
username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
||||
directory: args.dir,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@
|
|||
// an isolated test provider config under the fixture's temp home.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { reply } from "../../lib/llm-server"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { testProviderConfig } from "../../lib/test-provider"
|
||||
|
||||
const opencodeRoot = path.resolve(import.meta.dir, "../../..")
|
||||
|
||||
describe("opencode run (non-interactive subprocess)", () => {
|
||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||
// If this fails, all the others likely will too — debug here first.
|
||||
|
|
@ -367,9 +370,12 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("variant response")
|
||||
const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
|
||||
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
|
||||
})
|
||||
const result = yield* opencode.spawn(
|
||||
["run", "--model", "test/test-model", "--variant", "default", "use the model"],
|
||||
{
|
||||
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
|
||||
},
|
||||
)
|
||||
|
||||
opencode.expectExit(result, 0)
|
||||
expect(result.stdout).toBe("variant response\n")
|
||||
|
|
@ -382,7 +388,15 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const source = `${home}/image.png`
|
||||
yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64")))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
source,
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* llm.text("attachment received")
|
||||
const config = testProviderConfig(llm.url)
|
||||
config.provider.test.models["test-model"].attachment = true
|
||||
|
|
@ -395,7 +409,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||
opencode.expectExit(result, 0)
|
||||
const input = JSON.stringify(yield* llm.inputs)
|
||||
expect(input).toContain("image/png")
|
||||
expect(input).not.toContain("<file name=\\\"image.png\\\">")
|
||||
expect(input).not.toContain('<file name=\\"image.png\\">')
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
|
@ -422,6 +436,26 @@ describe("opencode run (non-interactive subprocess)", () => {
|
|||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"attach mode without --dir uses the remote server location",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(home).not.toBe(opencodeRoot)
|
||||
yield* llm.text("remote location used")
|
||||
const server = yield* opencode.serve()
|
||||
|
||||
const result = yield* opencode.run("use the server location", {
|
||||
extraArgs: ["--attach", server.url],
|
||||
})
|
||||
|
||||
opencode.expectExit(result, 0)
|
||||
const input = JSON.stringify(yield* llm.inputs)
|
||||
expect(input).toContain(`Working directory: ${opencodeRoot}`)
|
||||
expect(input).not.toContain(`Working directory: ${home}`)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.concurrent(
|
||||
"attach mode rejects local directories before prompt admission",
|
||||
({ home, opencode }) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue