node v2 cli support (#36309)
This commit is contained in:
parent
27e1692848
commit
a1b274e6f8
75 changed files with 1502 additions and 367 deletions
|
|
@ -31,11 +31,13 @@ function run(target) {
|
|||
|
||||
const envPath = process.env.OPENCODE_BIN_PATH
|
||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||
const cached = path.join(scriptDir, ".opencode2")
|
||||
const command = path.basename(__filename).replace(/\.cjs$/, "")
|
||||
const nodeBuild = command === "opencode2-node"
|
||||
const cached = path.join(scriptDir, `.${command}`)
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
||||
const base = `@opencode-ai/cli${nodeBuild ? "-node" : ""}-` + platform + "-" + arch
|
||||
const binary = platform === "windows" ? `${command}.exe` : command
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
|
|
@ -77,6 +79,7 @@ function supportsAvx2() {
|
|||
}
|
||||
|
||||
const names = (() => {
|
||||
if (nodeBuild) return [base]
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
if (platform === "linux") {
|
||||
const musl = (() => {
|
||||
|
|
@ -121,7 +124,7 @@ function findBinary(startDir) {
|
|||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
|
||||
`It seems that your package manager failed to install the right ${command} CLI package. Try manually installing ` +
|
||||
names.map((name) => `"${name}"`).join(" or ") +
|
||||
" package",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"build:node": "bun run script/build-node.ts",
|
||||
"dev": "bun run src/index.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
|
|
@ -51,7 +52,8 @@
|
|||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3"
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
|
@ -59,6 +61,19 @@
|
|||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
packages/cli/script/build-node.ts
Normal file
201
packages/cli/script/build-node.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { build } from "vite"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const outdir = path.resolve(
|
||||
dir,
|
||||
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
|
||||
)
|
||||
if (outdir === dir) throw new Error("--outdir must not be the package directory")
|
||||
if (outdir === path.join(dir, "dist-node")) {
|
||||
throw new Error("--outdir must not be dist-node because it contains temporary files")
|
||||
}
|
||||
const bundleOnly = process.argv.includes("--bundle-only")
|
||||
const single = process.argv.includes("--single")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
|
||||
const allTargets = [
|
||||
nodeTarget("linux", "arm64"),
|
||||
nodeTarget("linux", "x64"),
|
||||
nodeTarget("darwin", "arm64"),
|
||||
nodeTarget("win32", "arm64"),
|
||||
nodeTarget("win32", "x64"),
|
||||
]
|
||||
const targets = requested
|
||||
? allTargets.filter((target) => targetName(target) === requested)
|
||||
: single || bundleOnly
|
||||
? [nodeTarget(process.platform, process.arch)]
|
||||
: allTargets
|
||||
|
||||
if (targets.length === 0) {
|
||||
if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
|
||||
throw new Error(`Unknown Node target: ${requested}`)
|
||||
}
|
||||
if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
|
||||
throw new Error("Node 26.4 SEA does not support macOS x64")
|
||||
}
|
||||
|
||||
process.chdir(dir)
|
||||
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
|
||||
if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
|
||||
const builder =
|
||||
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
|
||||
? await resolveHostNode()
|
||||
: undefined
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`building cli-node-${targetName(target)}`)
|
||||
const assets = await collectNodeAssets(target)
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
await build(mainConfig(input))
|
||||
await copyNodeAssets(assets)
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
if (!builder) throw new Error("Node SEA builder is unavailable")
|
||||
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--version"])
|
||||
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--help"])
|
||||
}
|
||||
if (bundleOnly) continue
|
||||
|
||||
const name = `cli-node-${targetName(target)}`
|
||||
const binary = target.platform === "win32" ? "opencode2-node.exe" : "opencode2-node"
|
||||
const output = path.join(outdir, name, "bin", binary)
|
||||
if (!builder) throw new Error("Node SEA builder is unavailable")
|
||||
await mkdir(path.dirname(output), { recursive: true })
|
||||
const config = {
|
||||
main: "dist-node/opencode.mjs",
|
||||
mainFormat: "module",
|
||||
executable: await resolveTargetNode(target, builder),
|
||||
output: path.relative(dir, output),
|
||||
disableExperimentalSEAWarning: true,
|
||||
useSnapshot: false,
|
||||
useCodeCache: false,
|
||||
execArgv: nodeExecArgv,
|
||||
execArgvExtension: "none",
|
||||
assets: await seaAssetMap(),
|
||||
}
|
||||
await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
|
||||
run(builder, ["--build-sea", "dist-node/sea.json"])
|
||||
if (target.platform !== "win32") await chmod(output, 0o755)
|
||||
if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
|
||||
if (target.platform === "darwin" && process.platform !== "darwin") {
|
||||
console.warn(`${output} must be signed on macOS before it can run`)
|
||||
}
|
||||
await writeFile(
|
||||
path.join(outdir, name, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `@opencode-ai/${name}`,
|
||||
version: Script.version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
os: [target.platform],
|
||||
cpu: [target.arch],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
if (host) await smoke(output)
|
||||
}
|
||||
|
||||
async function resolveHostNode() {
|
||||
const candidates = [process.env.NODE_BIN, "node"].filter((item): item is string => Boolean(item))
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync(
|
||||
candidate,
|
||||
["-p", "JSON.stringify({version:process.versions.node,path:process.execPath})"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
)
|
||||
if (result.status !== 0) continue
|
||||
const info = JSON.parse(result.stdout) as { version: string; path: string }
|
||||
if (info.version === NODE_VERSION) return realpath(info.path)
|
||||
}
|
||||
return resolveTargetNode(nodeTarget(process.platform, process.arch))
|
||||
}
|
||||
|
||||
async function resolveTargetNode(target: NodeTarget, host?: string) {
|
||||
if (host && target.platform === process.platform && target.arch === process.arch) return host
|
||||
const cache = path.resolve(dir, ".cache", "node")
|
||||
const platform = target.platform === "win32" ? "win" : target.platform
|
||||
const archiveName = `node-v${NODE_VERSION}-${platform}-${target.arch}`
|
||||
const targetDirectory = path.join(cache, archiveName)
|
||||
const executable = path.join(targetDirectory, target.platform === "win32" ? "node.exe" : "bin/node")
|
||||
if (
|
||||
(await stat(executable).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)) &&
|
||||
(await stat(path.join(targetDirectory, ".verified")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
)
|
||||
return realpath(executable)
|
||||
await mkdir(cache, { recursive: true })
|
||||
const extension = target.platform === "win32" ? "zip" : "tar.gz"
|
||||
const filename = `${archiveName}.${extension}`
|
||||
const archive = path.join(cache, filename)
|
||||
const base = `https://nodejs.org/dist/v${NODE_VERSION}`
|
||||
const [response, sums] = await Promise.all([fetch(`${base}/${filename}`), fetch(`${base}/SHASUMS256.txt`)])
|
||||
if (!response.ok) throw new Error(`Failed to download Node ${NODE_VERSION}: ${response.status}`)
|
||||
if (!sums.ok) throw new Error(`Failed to download Node ${NODE_VERSION} checksums: ${sums.status}`)
|
||||
const data = new Uint8Array(await response.arrayBuffer())
|
||||
const expected = (await sums.text())
|
||||
.split("\n")
|
||||
.find((line) => line.endsWith(` ${filename}`))
|
||||
?.split(/\s+/)[0]
|
||||
if (!expected) throw new Error(`Missing checksum for ${filename}`)
|
||||
if (createHash("sha256").update(data).digest("hex") !== expected) throw new Error(`Checksum mismatch for ${filename}`)
|
||||
await writeFile(archive, data)
|
||||
const temporary = path.join(cache, `${archiveName}.${process.pid}.tmp`)
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
await mkdir(temporary)
|
||||
if (target.platform !== "win32") run("tar", ["-xzf", archive, "-C", temporary])
|
||||
else if (process.platform === "win32") run("tar", ["-xf", archive, "-C", temporary])
|
||||
else run("unzip", ["-q", archive, "-d", temporary])
|
||||
await rm(targetDirectory, { recursive: true, force: true })
|
||||
await rename(path.join(temporary, archiveName), targetDirectory)
|
||||
await writeFile(path.join(targetDirectory, ".verified"), `${expected}\n`)
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
await rm(archive, { force: true })
|
||||
return realpath(executable)
|
||||
}
|
||||
|
||||
async function smoke(output: string) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-node-smoke-"))
|
||||
const executable = path.join(root, path.basename(output))
|
||||
await copyFile(output, executable)
|
||||
if (process.platform !== "win32") await chmod(executable, 0o755)
|
||||
run(executable, ["--version"], root)
|
||||
run(executable, ["--help"], root)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function targetName(target: NodeTarget) {
|
||||
return `${target.platform === "win32" ? "windows" : target.platform}-${target.arch}`
|
||||
}
|
||||
|
||||
function run(command: string, args: readonly string[], cwd = dir) {
|
||||
const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
|
@ -11,9 +10,14 @@ import { modelsData } from "./generate"
|
|||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
const outdir = path.resolve(
|
||||
dir,
|
||||
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
|
||||
)
|
||||
if (outdir === dir) throw new Error("--outdir must not be the package directory")
|
||||
process.chdir(dir)
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
await rm(outdir, { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
|
|
@ -50,10 +54,6 @@ const targets = singleFlag
|
|||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
|
||||
|
||||
for (const item of targets) {
|
||||
const target = [
|
||||
binary,
|
||||
|
|
@ -67,7 +67,7 @@ for (const item of targets) {
|
|||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts", parserWorker],
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
|
|
@ -81,7 +81,7 @@ for (const item of targets) {
|
|||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||
outfile: `./dist/${name}/bin/${binary}`,
|
||||
outfile: path.join(outdir, name, "bin", binary),
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
|
|
@ -93,10 +93,6 @@ for (const item of targets) {
|
|||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
||||
OTUI_TREE_SITTER_WORKER_PATH:
|
||||
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
|
||||
path.relative(dir, parserWorker).replaceAll("\\", "/") +
|
||||
'"',
|
||||
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
||||
},
|
||||
})
|
||||
|
|
@ -107,7 +103,7 @@ for (const item of targets) {
|
|||
}
|
||||
|
||||
await Bun.write(
|
||||
`./dist/${name}/package.json`,
|
||||
path.join(outdir, name, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: `@opencode-ai/${name}`,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
|
||||
export const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
|
||||
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
|
||||
|
||||
console.log("Loaded models.dev snapshot")
|
||||
|
|
|
|||
83
packages/cli/script/node-assets.ts
Normal file
83
packages/cli/script/node-assets.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { createHash } from "node:crypto"
|
||||
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
// Bun's compiler discovers file imports and embeds them in its virtual filesystem. Vite only bundles the JavaScript
|
||||
// portion of the Node executable, while SEA embeds only the assets explicitly listed in its build configuration.
|
||||
// Collect and stage those files under stable keys so the SEA prelude can extract them to real paths at startup;
|
||||
// native addons, helper executables, and other path-based consumers cannot use assets directly from SEA memory.
|
||||
export type NodeAsset = {
|
||||
readonly key: string
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
const assets: NodeAsset[] = [
|
||||
...getNodeAssets({
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
}),
|
||||
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||
{
|
||||
key: photonWasmAsset,
|
||||
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
|
||||
},
|
||||
...attentionSoundAssets.map((key) => ({
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(await files(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
key: `${target.nodePtyPackage}/${relative}`,
|
||||
source: path.join(ptyRoot, relative),
|
||||
})),
|
||||
]
|
||||
await Promise.all(assets.map((asset) => stat(asset.source)))
|
||||
return assets
|
||||
}
|
||||
|
||||
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
||||
const hash = createHash("sha256")
|
||||
for (const asset of assets.toSorted((left, right) => left.key.localeCompare(right.key))) {
|
||||
hash.update(asset.key)
|
||||
hash.update(await readFile(asset.source))
|
||||
}
|
||||
return hash.digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
export async function copyNodeAssets(assets: readonly NodeAsset[]) {
|
||||
const root = path.join(dir, "dist-node", "assets")
|
||||
await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const target = path.join(root, asset.key)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await copyFile(asset.source, target)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function seaAssetMap() {
|
||||
const root = path.join(dir, "dist-node", "assets")
|
||||
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
|
||||
}
|
||||
|
|
@ -18,37 +18,55 @@ async function publish(dir: string, name: string, version: string) {
|
|||
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||
}
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
|
||||
const item = await Bun.file(`./dist/${filepath}`).json()
|
||||
binaries[item.name] = item.version
|
||||
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
|
||||
const item = await Bun.file(`${input.root}/${filepath}`).json()
|
||||
if (!item.name.startsWith(input.packagePrefix)) continue
|
||||
binaries[item.name] = item.version
|
||||
}
|
||||
console.log(input.name, "binaries", binaries)
|
||||
const versions = new Set(Object.values(binaries))
|
||||
if (versions.size > 1) throw new Error(`Binary package versions do not match for ${input.name}`)
|
||||
const version = versions.values().next().value
|
||||
if (!version) throw new Error(`No binary packages found for ${input.name}`)
|
||||
|
||||
await $`mkdir -p ${input.root}/${input.name}/bin`
|
||||
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
|
||||
await Bun.file(`${input.root}/${input.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: input.name,
|
||||
bin: { [input.binary]: `./bin/${input.binary}` },
|
||||
version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(binaries).map(([name, version]) =>
|
||||
publish(`${input.root}/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||
),
|
||||
)
|
||||
await publish(`${input.root}/${input.name}`, input.name, version)
|
||||
}
|
||||
console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
const name = pkg.name
|
||||
|
||||
await $`mkdir -p ./dist/${name}/bin`
|
||||
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
|
||||
await Bun.file(`./dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
bin: { opencode2: "./bin/opencode2" },
|
||||
version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(binaries).map(([name, version]) =>
|
||||
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||
),
|
||||
)
|
||||
await publish(`./dist/${name}`, name, version)
|
||||
await publishDistribution({
|
||||
root: "./dist",
|
||||
name: pkg.name,
|
||||
binary: "opencode2",
|
||||
packagePrefix: "@opencode-ai/cli-",
|
||||
})
|
||||
await publishDistribution({
|
||||
root: "./dist/node",
|
||||
name: "opencode2-node",
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import fs from "node:fs/promises"
|
|||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
|
||||
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
|
||||
const nodeBuild = process.argv.includes("--node")
|
||||
const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||
const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin")
|
||||
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readFile, stat, writeFile } from "node:fs/promises"
|
||||
import { Effect, Option } from "effect"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
|
@ -35,7 +36,7 @@ export default Runtime.handler(
|
|||
}),
|
||||
)
|
||||
|
||||
async function resolveConfigPath(directory: string) {
|
||||
export async function resolveConfigPath(directory: string) {
|
||||
const candidates = [
|
||||
path.join(directory, "opencode.json"),
|
||||
path.join(directory, "opencode.jsonc"),
|
||||
|
|
@ -43,16 +44,24 @@ async function resolveConfigPath(directory: string) {
|
|||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) return candidate
|
||||
if (
|
||||
await stat(candidate).then(
|
||||
(info) => info.isFile(),
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
return candidate
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function write(configPath: string, name: string, server: unknown) {
|
||||
const file = Bun.file(configPath)
|
||||
const text = (await file.exists()) ? await file.text() : "{}"
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
|
||||
throw error
|
||||
})
|
||||
const edits = modify(text, ["mcp", "servers", name], server, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
await Bun.write(configPath, applyEdits(text, edits))
|
||||
await writeFile(configPath, applyEdits(text, edits))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import { stringWidth } from "@opencode-ai/tui/util/string-width"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
|
||||
|
|
@ -196,7 +197,7 @@ export function RunFooterMenu(props: {
|
|||
...props
|
||||
.items()
|
||||
.filter((item) => item.description)
|
||||
.map((item) => Bun.stringWidth(item.display)),
|
||||
.map((item) => stringWidth(item.display)),
|
||||
)
|
||||
return width === 0 ? 0 : width + 2
|
||||
})
|
||||
|
|
@ -205,14 +206,14 @@ export function RunFooterMenu(props: {
|
|||
return ""
|
||||
}
|
||||
|
||||
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
|
||||
return " ".repeat(Math.max(1, descriptionColumn() - stringWidth(item.display)))
|
||||
}
|
||||
const descriptionText = (item: RunFooterMenuItem) => {
|
||||
if (!item.description) {
|
||||
return
|
||||
}
|
||||
|
||||
const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
|
||||
const footerWidth = item.footer ? stringWidth(item.footer) + 1 : 0
|
||||
const available =
|
||||
term().width -
|
||||
(border() ? 1 : 0) -
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@
|
|||
// It produces a PromptState that RunPromptBody renders as a slim single-line
|
||||
// composer while the footer view renders any active menus below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { pathToFileURL } from "bun"
|
||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import { stringWidth } from "@opencode-ai/tui/util/string-width"
|
||||
import {
|
||||
createPromptHistory,
|
||||
displayCharAt,
|
||||
|
|
@ -602,7 +603,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
})
|
||||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
|
||||
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
|
||||
draft = clonePrompt(value)
|
||||
setShell(value.mode === "shell")
|
||||
if (!area || area.isDestroyed) {
|
||||
|
|
@ -612,7 +613,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
hide()
|
||||
area.setText(value.text)
|
||||
restoreParts(value.parts)
|
||||
area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
|
||||
area.cursorOffset = Math.min(cursor, stringWidth(area.plainText))
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
|
@ -643,7 +644,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
area.setText(text)
|
||||
clearParts()
|
||||
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
|
||||
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
|
||||
area.cursorOffset = Math.min(stringWidth(text), stringWidth(area.plainText))
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
|
@ -777,7 +778,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
if (move(dir, event)) return
|
||||
if (!area || area.isDestroyed) return false
|
||||
|
||||
const endOffset = Bun.stringWidth(area.plainText)
|
||||
const endOffset = stringWidth(area.plainText)
|
||||
if (dir === -1) {
|
||||
if (area.cursorOffset === 0) return false
|
||||
if (area.visualCursor.visualRow === 0) {
|
||||
|
|
@ -886,16 +887,12 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
area.cursorOffset = 0
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset =
|
||||
shell() || !head
|
||||
? cursor
|
||||
: local
|
||||
? Bun.stringWidth(area.plainText)
|
||||
: Bun.stringWidth(area.plainText.slice(0, head.end))
|
||||
shell() || !head ? cursor : local ? stringWidth(area.plainText) : stringWidth(area.plainText.slice(0, head.end))
|
||||
const end = area.logicalCursor
|
||||
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
area.insertText(text)
|
||||
area.cursorOffset = Bun.stringWidth(text)
|
||||
area.cursorOffset = stringWidth(text)
|
||||
hide()
|
||||
syncDraft()
|
||||
if (!shell()) {
|
||||
|
|
@ -920,7 +917,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
|
||||
const text = "@" + next.value
|
||||
const startOffset = at()
|
||||
const endOffset = startOffset + Bun.stringWidth(text)
|
||||
const endOffset = startOffset + stringWidth(text)
|
||||
const part = structuredClone(next.part)
|
||||
if (part.type === "agent") {
|
||||
part.source = {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { ServerConnection } from "../services/server-connection"
|
|||
import { waitForCatalogReady } from "./catalog.shared"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||
import type { RunInput, RunTuiConfig } from "./types"
|
||||
import { readStdin } from "../util/io"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
|
||||
export type MiniCommandInput = {
|
||||
server: ServerConnection.Resolved
|
||||
|
|
@ -22,7 +24,7 @@ export type MiniCommandInput = {
|
|||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
||||
export async function runMini(input: MiniCommandInput) {
|
||||
validate(input)
|
||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
||||
const runtimeTask = import("./runtime")
|
||||
const directory = localDirectory()
|
||||
|
||||
|
|
@ -123,7 +125,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
|
|||
return
|
||||
}
|
||||
if (agent) return name
|
||||
await Bun.sleep(25)
|
||||
await setTimeout(25)
|
||||
}
|
||||
if (!agents) {
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { UI } from "./ui"
|
||||
import type { MiniToolPart } from "./types"
|
||||
|
||||
|
|
@ -478,11 +479,11 @@ async function prepareFile(file: File) {
|
|||
if (file.mime !== "text/plain") {
|
||||
const uri = file.url.startsWith("data:")
|
||||
? file.url
|
||||
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
|
||||
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
|
||||
return { attachment: { uri, mime: file.mime, name: file.filename } }
|
||||
}
|
||||
const content = file.url.startsWith("data:")
|
||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||
: await Bun.file(new URL(file.url)).text()
|
||||
: await readFile(new URL(file.url), "utf8")
|
||||
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
// the current draft is saved and history begins. Arrowing past the end
|
||||
// restores the draft.
|
||||
export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display"
|
||||
import { stringWidth } from "@opencode-ai/tui/util/string-width"
|
||||
import type { RunPrompt } from "./types"
|
||||
|
||||
const HISTORY_LIMIT = 200
|
||||
|
|
@ -102,7 +103,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
|
|||
return { state, apply: false }
|
||||
}
|
||||
|
||||
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
|
||||
if (dir === 1 && cursor !== stringWidth(text)) {
|
||||
return { state, apply: false }
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +137,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
|
|||
index: null,
|
||||
},
|
||||
text: state.draft,
|
||||
cursor: Bun.stringWidth(state.draft),
|
||||
cursor: stringWidth(state.draft),
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -147,7 +148,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
|
|||
index: idx,
|
||||
},
|
||||
text: state.items[idx].text,
|
||||
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
|
||||
cursor: dir === -1 ? 0 : stringWidth(state.items[idx].text),
|
||||
apply: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||
import { Model } from "@opencode-ai/schema/model"
|
||||
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 { runNonInteractivePrompt } from "./noninteractive"
|
||||
|
|
@ -48,7 +49,7 @@ async function run(input: RunCommandInput) {
|
|||
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 message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
|
||||
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 prepared = { directory, message, files }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
|
|
@ -161,7 +162,7 @@ async function prepareFile(file: RunFilePart) {
|
|||
if (file.mime !== "text/plain") return { attachment: { uri: file.url, name: file.filename } }
|
||||
const content = file.url.startsWith("data:")
|
||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||
: await Bun.file(new URL(file.url)).text()
|
||||
: await readFile(new URL(file.url), "utf8")
|
||||
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
|
||||
|
|
|
|||
3
packages/cli/src/node/index.ts
Normal file
3
packages/cli/src/node/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import "./plugin-runtime.promise"
|
||||
import "./plugin-runtime.effect"
|
||||
import "../index"
|
||||
28
packages/cli/src/node/plugin-runtime.effect.ts
Normal file
28
packages/cli/src/node/plugin-runtime.effect.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
Agent,
|
||||
Command,
|
||||
Connection,
|
||||
Credential,
|
||||
Integration,
|
||||
Model,
|
||||
Plugin,
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2/effect"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.effect")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
Agent,
|
||||
Command,
|
||||
Connection,
|
||||
Credential,
|
||||
Integration,
|
||||
Model,
|
||||
Plugin,
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
}
|
||||
26
packages/cli/src/node/plugin-runtime.promise.ts
Normal file
26
packages/cli/src/node/plugin-runtime.promise.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
Agent,
|
||||
Command,
|
||||
Connection,
|
||||
Credential,
|
||||
Integration,
|
||||
Model,
|
||||
Plugin,
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
Agent,
|
||||
Command,
|
||||
Connection,
|
||||
Credential,
|
||||
Integration,
|
||||
Model,
|
||||
Plugin,
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
}
|
||||
34
packages/cli/src/node/target.ts
Normal file
34
packages/cli/src/node/target.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const platforms = ["darwin", "linux", "win32"] as const
|
||||
|
||||
export type NodeTarget = ReturnType<typeof nodeTarget>
|
||||
|
||||
export function nodeTarget(platform: string, arch: string) {
|
||||
if (!platforms.includes(platform as (typeof platforms)[number]) || (arch !== "arm64" && arch !== "x64")) {
|
||||
throw new Error(`Unsupported Node executable target: ${platform}-${arch}`)
|
||||
}
|
||||
|
||||
const targetPlatform = platform as (typeof platforms)[number]
|
||||
const targetArch = arch as "arm64" | "x64"
|
||||
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
arch: targetArch,
|
||||
nodePtyPackage,
|
||||
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
||||
parcelWatcherPackage,
|
||||
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
||||
}
|
||||
}
|
||||
|
||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||
|
||||
export const attentionSoundAssets = [
|
||||
"@opencode-ai/ui/audio/bip-bop-01.mp3",
|
||||
"@opencode-ai/ui/audio/bip-bop-03.mp3",
|
||||
"@opencode-ai/ui/audio/staplebops-06.mp3",
|
||||
"@opencode-ai/ui/audio/nope-03.mp3",
|
||||
"@opencode-ai/ui/audio/yup-01.mp3",
|
||||
] as const
|
||||
|
|
@ -5,6 +5,7 @@ import { Service } from "@opencode-ai/client/effect/service"
|
|||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { randomBytes } from "crypto"
|
||||
import path from "path"
|
||||
import { selfCommand } from "../util/process"
|
||||
|
||||
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
|
||||
// points the client package's service operations at this CLI: which
|
||||
|
|
@ -78,13 +79,10 @@ const paths = Effect.gen(function* () {
|
|||
export const options = Effect.fnUntraced(function* () {
|
||||
const { file, legacyFile } = yield* paths
|
||||
yield* migrateRegistration(legacyFile, file)
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
return {
|
||||
file,
|
||||
version: InstallationVersion,
|
||||
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { Effect, Schema, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { selfCommand } from "../util/process"
|
||||
|
||||
const Ready = Schema.Struct({ url: Schema.String })
|
||||
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
|
||||
|
|
@ -14,10 +14,7 @@ type Options = {
|
|||
}
|
||||
|
||||
function command(password: string, options: Options) {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
|
||||
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
|
||||
const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
|
||||
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
|
||||
if (!executable) throw new Error("Failed to resolve standalone server command")
|
||||
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
|
||||
cwd: process.cwd(),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||
import { go } from "@opencode-ai/tui/logo"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
|
|
@ -123,7 +124,7 @@ async function open(from?: string): Promise<Session> {
|
|||
let shownAt = performance.now()
|
||||
const waitForStage = async () => {
|
||||
const remaining = stageFloor - (performance.now() - shownAt)
|
||||
if (remaining > 0) await Bun.sleep(remaining)
|
||||
if (remaining > 0) await setTimeout(remaining)
|
||||
}
|
||||
const advance = async (stage: number) => {
|
||||
await waitForStage()
|
||||
|
|
@ -140,11 +141,11 @@ async function open(from?: string): Promise<Session> {
|
|||
setOutcome(next)
|
||||
const completed = await Promise.race([
|
||||
settled.promise.then(() => true),
|
||||
Bun.sleep(transitionDuration + 500).then(() => false),
|
||||
setTimeout(transitionDuration + 500).then(() => false),
|
||||
])
|
||||
resolveOutcome = undefined
|
||||
setAnimating(false)
|
||||
if (completed) await Bun.sleep(hold)
|
||||
if (completed) await setTimeout(hold)
|
||||
}
|
||||
let closing: Promise<void> | undefined
|
||||
let transferred = false
|
||||
|
|
@ -154,7 +155,7 @@ async function open(from?: string): Promise<Session> {
|
|||
setAnimating(false)
|
||||
if (renderer.isDestroyed) return
|
||||
renderer.pause()
|
||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||
await Promise.race([renderer.idle(), setTimeout(500)])
|
||||
renderer.destroy()
|
||||
})())
|
||||
let loading: Promise<void> | undefined
|
||||
|
|
@ -178,7 +179,7 @@ async function open(from?: string): Promise<Session> {
|
|||
renderer.screenMode = "alternate-screen"
|
||||
renderer.consoleMode = "console-overlay"
|
||||
renderer.requestRender()
|
||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||
await Promise.race([renderer.idle(), setTimeout(500)])
|
||||
transferred = true
|
||||
return {
|
||||
renderer,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@ import { parse, type ParseError } from "jsonc-parser"
|
|||
import path from "node:path"
|
||||
import semver from "semver"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName = "@opencode-ai/cli"
|
||||
const packageName =
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
|
||||
? OPENCODE_CLI_NAME
|
||||
: "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
|
|
|
|||
5
packages/cli/src/util/io.ts
Normal file
5
packages/cli/src/util/io.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { text } from "node:stream/consumers"
|
||||
|
||||
export function readStdin() {
|
||||
return text(process.stdin)
|
||||
}
|
||||
26
packages/cli/src/util/process.ts
Normal file
26
packages/cli/src/util/process.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import path from "node:path"
|
||||
|
||||
export function selfCommand() {
|
||||
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
|
||||
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
|
||||
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
|
||||
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
|
||||
return [process.execPath, process.argv[1]]
|
||||
}
|
||||
|
||||
function nodeFlags() {
|
||||
return process.execArgv.flatMap((arg, index, args) => {
|
||||
if (index > 0 && args[index - 1] === "--conditions") return []
|
||||
if (arg === "--conditions") return args[index + 1] ? [arg, args[index + 1]] : []
|
||||
if (arg.startsWith("--conditions=")) return [arg]
|
||||
if (
|
||||
arg === "--experimental-ffi" ||
|
||||
arg === "--use-system-ca" ||
|
||||
arg === "--enable-source-maps" ||
|
||||
arg === "--no-addons"
|
||||
)
|
||||
return [arg]
|
||||
if (arg === "--no-warnings" || arg.startsWith("--disable-warning=")) return [arg]
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
|
@ -6,5 +6,6 @@
|
|||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
},
|
||||
"exclude": ["dist", "dist-node"]
|
||||
}
|
||||
|
|
|
|||
220
packages/cli/vite.node.config.ts
Normal file
220
packages/cli/vite.node.config.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import path from "node:path"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
function rawTextPlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:raw-text",
|
||||
async load(id) {
|
||||
if (!id.endsWith(".md")) return
|
||||
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRequirePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:runtime-require",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
if (!id.endsWith("turndown/lib/turndown.es.js")) return
|
||||
const transformed = code.replace(" var domino = require('@mixmark-io/domino');", "")
|
||||
if (transformed === code) this.error("Failed to rewrite Turndown's Domino require")
|
||||
return `import domino from "@mixmark-io/domino"\n${transformed}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = {
|
||||
alias: [
|
||||
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
|
||||
{ find: /^solid-js$/, replacement: "solid-js/dist/solid.js" },
|
||||
{
|
||||
find: /^ws$/,
|
||||
replacement: path.join(path.dirname(createRequire(import.meta.url).resolve("ws/package.json")), "wrapper.mjs"),
|
||||
},
|
||||
],
|
||||
conditions: ["node"],
|
||||
}
|
||||
|
||||
const output = (entryFileNames: string, banner?: string) => ({
|
||||
format: "esm" as const,
|
||||
entryFileNames,
|
||||
inlineDynamicImports: true,
|
||||
banner,
|
||||
})
|
||||
|
||||
function nodePrelude(input: NodeBuildInput) {
|
||||
const nodePtySpawnHelper =
|
||||
input.target.platform === "darwin"
|
||||
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
|
||||
: undefined
|
||||
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Agent = sdk.Agent
|
||||
export const Command = sdk.Command
|
||||
export const Connection = sdk.Connection
|
||||
export const Credential = sdk.Credential
|
||||
export const Integration = sdk.Integration
|
||||
export const Model = sdk.Model
|
||||
export const Plugin = sdk.Plugin
|
||||
export const Provider = sdk.Provider
|
||||
export const Reference = sdk.Reference
|
||||
export const Skill = sdk.Skill`
|
||||
const effectModule = promiseModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const define = sdk.Plugin.define`
|
||||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
export const Failure = sdk.Tool.Failure
|
||||
export const RegistrationError = sdk.Tool.RegistrationError
|
||||
export const make = sdk.Tool.make
|
||||
export const validateName = sdk.Tool.validateName
|
||||
export const registrationEntries = sdk.Tool.registrationEntries
|
||||
export const withPermission = sdk.Tool.withPermission
|
||||
export const permission = sdk.Tool.permission
|
||||
export const definition = sdk.Tool.definition
|
||||
export const settle = sdk.Tool.settle`
|
||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||
import __cjs_mod__ from "node:module"
|
||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||
import { tmpdir as __ocTmpdir } from "node:os"
|
||||
import __ocPath from "node:path"
|
||||
import { getAssetKeys as __ocAssetKeys, getRawAsset as __ocRawAsset, isSea as __ocIsSea } from "node:sea"
|
||||
import { fileURLToPath as __ocFileURLToPath } from "node:url"
|
||||
const __filename = import.meta.filename
|
||||
const __dirname = import.meta.dirname
|
||||
const require = __cjs_mod__.createRequire(import.meta.url)
|
||||
const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
||||
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
})}
|
||||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
})}
|
||||
__cjs_mod__.registerHooks({
|
||||
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
|
||||
const __ocUrl = __ocPluginModules[__ocSpecifier]
|
||||
return __ocUrl ? { url: __ocUrl, shortCircuit: true } : __ocNextResolve(__ocSpecifier, __ocContext)
|
||||
},
|
||||
load(__ocUrl, __ocContext, __ocNextLoad) {
|
||||
const __ocSource = __ocPluginSources[__ocUrl]
|
||||
return __ocSource
|
||||
? { format: "module", source: __ocSource, shortCircuit: true }
|
||||
: __ocNextLoad(__ocUrl, __ocContext)
|
||||
},
|
||||
})
|
||||
const __ocUid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
const __ocCacheRoot = __ocPath.join(__ocTmpdir(), \`opencode-node-\${__ocUid ?? "user"}\`)
|
||||
if (__ocIsSea()) {
|
||||
try {
|
||||
__ocMkdir(__ocCacheRoot, { mode: 0o700 })
|
||||
} catch (__ocError) {
|
||||
if (!__ocExists(__ocCacheRoot)) throw __ocError
|
||||
}
|
||||
const __ocCacheInfo = __ocLstat(__ocCacheRoot)
|
||||
if (!__ocCacheInfo.isDirectory() || __ocCacheInfo.isSymbolicLink()) throw new Error("Unsafe Node asset cache path")
|
||||
if (__ocUid !== undefined && __ocCacheInfo.uid !== __ocUid) throw new Error("Node asset cache is owned by another user")
|
||||
if (__ocUid !== undefined) __ocChmod(__ocCacheRoot, 0o700)
|
||||
}
|
||||
const __ocAssetRoot = __ocIsSea()
|
||||
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
|
||||
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
|
||||
if (__ocIsSea()) {
|
||||
for (const __ocKey of __ocAssetKeys()) {
|
||||
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
|
||||
if (__ocExists(__ocTarget)) continue
|
||||
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
|
||||
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
|
||||
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
|
||||
try {
|
||||
__ocRename(__ocTemporary, __ocTarget)
|
||||
} catch (__ocError) {
|
||||
__ocRm(__ocTemporary, { force: true })
|
||||
if (!__ocExists(__ocTarget)) throw __ocError
|
||||
}
|
||||
}
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
|
||||
}
|
||||
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
|
||||
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
|
||||
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||
}
|
||||
|
||||
export type NodeBuildInput = {
|
||||
readonly version: string
|
||||
readonly channel: string
|
||||
readonly models: string
|
||||
readonly assetHash: string
|
||||
readonly target: NodeTarget
|
||||
}
|
||||
|
||||
export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
return defineConfig({
|
||||
root: dir,
|
||||
plugins: [
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
moduleName: "@opentui/solid",
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve,
|
||||
esbuild: { jsx: "automatic" },
|
||||
define: {
|
||||
OPENCODE_VERSION: JSON.stringify(input.version),
|
||||
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
|
||||
OPENCODE_MODELS_DEV: input.models,
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
},
|
||||
ssr: { noExternal: true },
|
||||
build: {
|
||||
ssr: "src/node/index.ts",
|
||||
target: "node26",
|
||||
outDir: "dist-node",
|
||||
emptyOutDir: false,
|
||||
minify: true,
|
||||
rollupOptions: {
|
||||
external: [/^@opencode-ai\/simulation(?:\/|$)/],
|
||||
output: output("opencode.mjs", nodePrelude(input)),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default mainConfig({
|
||||
version: process.env.OPENCODE_VERSION ?? "local",
|
||||
channel: process.env.OPENCODE_CHANNEL ?? "local",
|
||||
models: "undefined",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue