diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8981aad49a..5b02651cf4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -121,6 +121,53 @@ jobs: outputs: version: ${{ needs.version.outputs.version }} + build-node-cli: + needs: version + if: github.repository == 'anomalyco/opencode' + strategy: + fail-fast: false + matrix: + settings: + - target: linux-arm64 + host: blacksmith-4vcpu-ubuntu-2404-arm + - target: linux-x64 + host: blacksmith-4vcpu-ubuntu-2404 + - target: darwin-arm64 + host: macos-26 + - target: windows-arm64 + host: windows-2025 + - target: windows-x64 + host: blacksmith-4vcpu-windows-2025 + runs-on: ${{ matrix.settings.host }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + - uses: ./.github/actions/setup-bun + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "26.4.0" + + - name: Build + run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --outdir=dist/node + env: + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + OPENCODE_RELEASE: ${{ needs.version.outputs.release }} + + - name: Verify service lifecycle + if: matrix.settings.target != 'windows-arm64' + working-directory: packages/cli + run: bun run script/service-smoke.ts --node + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: opencode-node-cli-${{ matrix.settings.target }} + path: packages/cli/dist/node/cli-node-* + if-no-files-found: error + sign-cli-windows: needs: - build-cli @@ -413,6 +460,7 @@ jobs: needs: - version - build-cli + - build-node-cli - sign-cli-windows - build-electron if: always() && !failure() && !cancelled() @@ -461,6 +509,12 @@ jobs: name: opencode-preview-cli path: packages/cli/dist + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: opencode-node-cli-* + path: packages/cli/dist/node + merge-multiple: true + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.version.outputs.release with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b2e87fc7a..b486b68a93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,6 +78,20 @@ jobs: bun run script/build.ts --single --skip-install bun run script/service-smoke.ts + - name: Setup Node build runtime + if: always() + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "26.4.0" + + - name: Verify Node build + if: always() + timeout-minutes: 15 + working-directory: packages/cli + run: | + bun run script/build-node.ts --single --skip-install --outdir=dist/node + bun run script/service-smoke.ts --node + - name: Check generated client if: runner.os == 'Linux' working-directory: packages/client diff --git a/.gitignore b/.gitignore index ba80d79b90..183888091c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules playground tmp dist +dist-node ts-dist .turbo .typecheck-profiles @@ -25,6 +26,7 @@ Session.vim a.out target .scripts +.cache .direnv/ # Local dev files diff --git a/bun.lock b/bun.lock index a4b5c11bcb..7d2f7ea453 100644 --- a/bun.lock +++ b/bun.lock @@ -145,14 +145,28 @@ "solid-js": "catalog:", "strip-ansi": "7.1.2", "uqr": "0.1.3", + "ws": "8.21.0", }, "devDependencies": { + "@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", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/script": "workspace:*", + "@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", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", + "vite": "catalog:", + "vite-plugin-solid": "catalog:", }, }, "packages/client": { @@ -387,6 +401,7 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", + "resolve.exports": "catalog:", "semver": "^7.6.3", "turndown": "7.2.0", "venice-ai-sdk-provider": "2.1.1", @@ -739,9 +754,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.3", - "@opentui/keymap": ">=0.4.3", - "@opentui/solid": ">=0.4.3", + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5", }, "optionalPeers": [ "@opentui/core", @@ -1021,10 +1036,12 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", + "get-east-asian-width": "catalog:", "open": "10.1.2", "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", + "string-width": "catalog:", "strip-ansi": "7.1.2", "uqr": "0.1.3", }, @@ -1190,9 +1207,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1220,6 +1237,7 @@ "drizzle-orm": "1.0.0-rc.2", "effect": "4.0.0-beta.83", "fuzzysort": "3.1.0", + "get-east-asian-width": "1.6.0", "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", @@ -1228,11 +1246,13 @@ "opentui-spinner": "0.0.7", "remeda": "2.26.0", "remend": "1.3.0", + "resolve.exports": "2.0.3", "semver": "7.7.4", "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", "sst": "4.13.1", + "string-width": "7.2.0", "tailwindcss": "4.1.11", "typescript": "5.8.2", "ulid": "3.0.1", @@ -2273,27 +2293,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="], + "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], - "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="], + "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], - "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="], + "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], @@ -5595,6 +5615,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], diff --git a/package.json b/package.json index 962f4ee3c1..45f424a363 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", @@ -75,6 +75,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", + "get-east-asian-width": "1.6.0", "luxon": "3.6.1", "marked": "17.0.6", "marked-shiki": "1.2.1", @@ -85,9 +86,11 @@ "@typescript/native-preview": "7.0.0-dev.20251207.1", "zod": "4.1.8", "remeda": "2.26.0", + "resolve.exports": "2.0.3", "sst": "4.13.1", "shiki": "4.2.0", "solid-list": "0.3.0", + "string-width": "7.2.0", "tailwindcss": "4.1.11", "vite": "7.1.4", "@solidjs/meta": "0.29.4", diff --git a/packages/cli/bin/opencode2.cjs b/packages/cli/bin/opencode2.cjs index 8795c71cd2..d3f1c9260d 100755 --- a/packages/cli/bin/opencode2.cjs +++ b/packages/cli/bin/opencode2.cjs @@ -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", ) diff --git a/packages/cli/package.json b/packages/cli/package.json index ed28f3779f..4859175a01 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:" } } diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts new file mode 100644 index 0000000000..0b4e29930e --- /dev/null +++ b/packages/cli/script/build-node.ts @@ -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"}`) +} diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index aa498c8141..ca3b84bb06 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -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}`, diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index d98565e298..b8151b46cf 100755 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -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") diff --git a/packages/cli/script/node-assets.ts b/packages/cli/script/node-assets.ts new file mode 100644 index 0000000000..749a0f74c2 --- /dev/null +++ b/packages/cli/script/node-assets.ts @@ -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 { + 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)])) +} diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index 116e5efa32..0c0cb66f21 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -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 = {} -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 = {} + 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-", +}) diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index c86e9f8280..2a51fc22ff 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -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-")) diff --git a/packages/cli/src/commands/handlers/mcp/add.ts b/packages/cli/src/commands/handlers/mcp/add.ts index 695104b3f2..dc582a0e00 100644 --- a/packages/cli/src/commands/handlers/mcp/add.ts +++ b/packages/cli/src/commands/handlers/mcp/add.ts @@ -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)) } diff --git a/packages/cli/src/mini/footer.menu.tsx b/packages/cli/src/mini/footer.menu.tsx index ab9bea05ea..1350d1f52f 100644 --- a/packages/cli/src/mini/footer.menu.tsx +++ b/packages/cli/src/mini/footer.menu.tsx @@ -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) - diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 89786a14d6..324cb62de9 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -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 = { diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 99ec8bcf3b..3a8d4a48cc 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -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> 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") diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 0ea58394a8..86e7cead2a 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -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: `\n${content}\n` } } diff --git a/packages/cli/src/mini/prompt.shared.ts b/packages/cli/src/mini/prompt.shared.ts index 56d1cb2968..359cac3dc1 100644 --- a/packages/cli/src/mini/prompt.shared.ts +++ b/packages/cli/src/mini/prompt.shared.ts @@ -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, } } diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index cc4b7c91f8..fd8f8c8f3a 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -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 } diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index 057801d2cd..c595aab88a 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -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: `\n${content}\n` } } diff --git a/packages/cli/src/node/index.ts b/packages/cli/src/node/index.ts new file mode 100644 index 0000000000..b7bb7db541 --- /dev/null +++ b/packages/cli/src/node/index.ts @@ -0,0 +1,3 @@ +import "./plugin-runtime.promise" +import "./plugin-runtime.effect" +import "../index" diff --git a/packages/cli/src/node/plugin-runtime.effect.ts b/packages/cli/src/node/plugin-runtime.effect.ts new file mode 100644 index 0000000000..5e38b00d4c --- /dev/null +++ b/packages/cli/src/node/plugin-runtime.effect.ts @@ -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, +} diff --git a/packages/cli/src/node/plugin-runtime.promise.ts b/packages/cli/src/node/plugin-runtime.promise.ts new file mode 100644 index 0000000000..93e20b87bb --- /dev/null +++ b/packages/cli/src/node/plugin-runtime.promise.ts @@ -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, +} diff --git a/packages/cli/src/node/target.ts b/packages/cli/src/node/target.ts new file mode 100644 index 0000000000..097d74974f --- /dev/null +++ b/packages/cli/src/node/target.ts @@ -0,0 +1,34 @@ +const platforms = ["darwin", "linux", "win32"] as const + +export type NodeTarget = ReturnType + +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 diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 2446d30730..52543fcbe4 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -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"], } }) diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index a3098e0076..273cccbf49 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -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(), diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index e471e798b0..f1dc57c460 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -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 { 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 { 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 | undefined let transferred = false @@ -154,7 +155,7 @@ async function open(from?: string): Promise { 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 | undefined @@ -178,7 +179,7 @@ async function open(from?: string): Promise { 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, diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 5f3083b7c9..fd46b90084 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -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 diff --git a/packages/cli/src/util/io.ts b/packages/cli/src/util/io.ts new file mode 100644 index 0000000000..e1995779a7 --- /dev/null +++ b/packages/cli/src/util/io.ts @@ -0,0 +1,5 @@ +import { text } from "node:stream/consumers" + +export function readStdin() { + return text(process.stdin) +} diff --git a/packages/cli/src/util/process.ts b/packages/cli/src/util/process.ts new file mode 100644 index 0000000000..a0c567aad5 --- /dev/null +++ b/packages/cli/src/util/process.ts @@ -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 [] + }) +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index ac9f4c63f7..0a60335b40 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -6,5 +6,6 @@ "jsxImportSource": "@opentui/solid", "lib": ["ESNext", "DOM", "DOM.Iterable"], "noUncheckedIndexedAccess": false - } + }, + "exclude": ["dist", "dist-node"] } diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts new file mode 100644 index 0000000000..791a0131ac --- /dev/null +++ b/packages/cli/vite.node.config.ts @@ -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), +}) diff --git a/packages/core/package.json b/packages/core/package.json index fd5a2e4318..5168904371 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,6 +37,21 @@ "bun": "./src/filesystem/fff.bun.ts", "node": "./src/filesystem/fff.node.ts", "default": "./src/filesystem/fff.bun.ts" + }, + "#photon-wasm": { + "bun": "./src/image/photon-wasm.bun.ts", + "node": "./src/image/photon-wasm.node.ts", + "default": "./src/image/photon-wasm.bun.ts" + }, + "#runtime-import": { + "bun": "./src/runtime/import.bun.ts", + "node": "./src/runtime/import.node.ts", + "default": "./src/runtime/import.bun.ts" + }, + "#process-lock-ffi": { + "bun": "./src/util/process-lock-ffi.bun.ts", + "node": "./src/util/process-lock-ffi.node.ts", + "default": "./src/util/process-lock-ffi.bun.ts" } }, "devDependencies": { @@ -121,6 +136,7 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", + "resolve.exports": "catalog:", "semver": "^7.6.3", "turndown": "7.2.0", "venice-ai-sdk-provider": "2.1.1", diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 9a72903d84..8c4e6265a2 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -11,10 +11,12 @@ import { Flag } from "../flag/flag" import { lazy } from "../util/lazy" import { watch as watchFileSystem } from "node:fs" import path from "path" +import { createRequire } from "node:module" declare const OPENCODE_LIBC: string | undefined const SUBSCRIBE_TIMEOUT_MS = 10_000 +const require = createRequire(import.meta.url) export const Event = { Updated: FileSystem.Event.Changed } @@ -22,7 +24,8 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { try { const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, + process.env.OPENCODE_PARCEL_WATCHER_PATH ?? + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, ) return createWrapper(binding) as typeof import("@parcel/watcher") } catch { diff --git a/packages/core/src/image/photon-wasm.bun.ts b/packages/core/src/image/photon-wasm.bun.ts new file mode 100644 index 0000000000..ed836cb529 --- /dev/null +++ b/packages/core/src/image/photon-wasm.bun.ts @@ -0,0 +1,4 @@ +// @ts-ignore Bun embeds static file imports when compiling the CLI. +import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } + +export default photonWasm diff --git a/packages/core/src/image/photon-wasm.node.ts b/packages/core/src/image/photon-wasm.node.ts new file mode 100644 index 0000000000..6147408070 --- /dev/null +++ b/packages/core/src/image/photon-wasm.node.ts @@ -0,0 +1,4 @@ +import { createRequire } from "node:module" + +export default process.env.OPENCODE_PHOTON_WASM_PATH ?? + createRequire(import.meta.url).resolve("@silvia-odwyer/photon-node/photon_rs_bg.wasm") diff --git a/packages/core/src/image/photon.ts b/packages/core/src/image/photon.ts index e4a00ce5fc..15902c7845 100644 --- a/packages/core/src/image/photon.ts +++ b/packages/core/src/image/photon.ts @@ -1,5 +1,4 @@ -// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm. -import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } +import photonWasm from "#photon-wasm" import { Effect } from "effect" import path from "node:path" import { fileURLToPath } from "node:url" diff --git a/packages/core/src/node-ffi.d.ts b/packages/core/src/node-ffi.d.ts new file mode 100644 index 0000000000..5f4360783e --- /dev/null +++ b/packages/core/src/node-ffi.d.ts @@ -0,0 +1,18 @@ +declare module "node:ffi" { + type Signature = { + readonly arguments?: readonly string[] + readonly return?: string + } + + type ForeignFunction = (...args: ReadonlyArray) => number | bigint + + export function dlopen( + path: string, + definitions: Readonly>, + ): { + readonly lib: { close(): void } + readonly functions: Readonly> + } + + export function getInt32(pointer: number | bigint, offset?: number): number +} diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 48e71da064..71bd69cf65 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -12,6 +12,7 @@ import { filesystem } from "./effect/app-node-platform" import { LayerNode } from "./effect/layer-node" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" +import { resolveModule } from "#runtime-import" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), @@ -54,9 +55,7 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[ const entrypoint = subpaths .map((subpath) => { try { - return typeof Bun !== "undefined" - ? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir) - : import.meta.resolve(dir) + return resolveModule([name, subpath].filter(Boolean).join("/"), dir) } catch { return undefined } diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index ae20d0d020..97bfd15ebd 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { pathToFileURL } from "url" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Npm } from "../../npm" +import { importModule } from "#runtime-import" export const DynamicProviderPlugin = define({ id: "opencode.provider.dynamic", @@ -17,11 +18,9 @@ export const DynamicProviderPlugin = define({ : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) - const mod = yield* Effect.promise(async () => { - return (await import( - installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href - )) as Record any> - }).pipe(Effect.orDie) + const mod = (yield* Effect.promise(() => + importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href), + ).pipe(Effect.orDie)) as Record any> const match = Object.keys(mod).find((name) => name.startsWith("create")) if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 559493787d..581a028cf9 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -3,6 +3,7 @@ import { pathToFileURL } from "url" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" +import { importModule } from "#runtime-import" export const SapAICorePlugin = define({ id: "opencode.provider.sap-ai-core", @@ -22,9 +23,9 @@ export const SapAICorePlugin = define({ : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`)) - const mod: Record = yield* Effect.promise( - () => import(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href), - ) + const mod = (yield* Effect.promise(() => + importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href), + )) as Record const match = Object.keys(mod).find((name) => name.startsWith("create")) if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`)) const factory = mod[match] diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index fce294998e..ad02bb33ed 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -39,6 +39,7 @@ import { WellKnown } from "../wellknown" import { PluginInternal } from "./internal" import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" +import { importModule } from "#runtime-import" const PluginModule = Schema.Struct({ default: Schema.Union([ @@ -164,9 +165,13 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract import(source)) + const mod = yield* Effect.promise(() => importModule(source)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) return { diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 4e43963d19..f4db71ca83 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,11 +1,11 @@ export * as ProviderV2 from "./provider" import { Effect, Schema } from "effect" -import { pathToFileURL } from "url" import { Provider } from "@opencode-ai/schema/provider" import type { ProviderPackageDefinition } from "@opencode-ai/ai" import { Npm } from "./npm" import type { DeepMutable } from "./schema" +import { importModule, resolveModule } from "#runtime-import" export const ID = Provider.ID export type ID = typeof ID.Type @@ -32,8 +32,24 @@ export class LoadError extends Schema.TaggedErrorClass()("ProviderV2. export type ProviderPackage = ProviderPackageDefinition const packages = new Map>() +const builtins = new Map Promise>([ + ["@opencode-ai/ai/providers/amazon-bedrock", () => import("@opencode-ai/ai/providers/amazon-bedrock")], + ["@opencode-ai/ai/providers/anthropic", () => import("@opencode-ai/ai/providers/anthropic")], + ["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")], + ["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")], + ["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")], + ["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")], + ["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")], + ["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")], + ["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")], + ["@opencode-ai/ai/providers/openai-compatible", () => import("@opencode-ai/ai/providers/openai-compatible")], + ["@opencode-ai/ai/providers/openrouter", () => import("@opencode-ai/ai/providers/openrouter")], + ["@opencode-ai/ai/providers/xai", () => import("@opencode-ai/ai/providers/xai")], +]) export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) { + const builtin = builtins.get(specifier) + if (builtin) return yield* importPackage(specifier, specifier, builtin) const resolved = yield* Effect.sync(() => { if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/ai/")) return specifier try { @@ -53,7 +69,8 @@ export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specif const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier) const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause }))) const entrypoint = yield* Effect.try({ - try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href), + try: () => + specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory), catch: (cause) => new LoadError({ package: specifier, cause }), }) return yield* importPackage(specifier, entrypoint) @@ -116,12 +133,16 @@ export type Info = Provider.Info export type MutableInfo = DeepMutable -const importPackage = Effect.fn("ProviderV2.importPackage")(function* (specifier: string, entrypoint: string) { +const importPackage = Effect.fn("ProviderV2.importPackage")(function* ( + specifier: string, + entrypoint: string, + load = () => importModule(entrypoint), +) { const module = yield* Effect.tryPromise({ try: () => { const existing = packages.get(entrypoint) if (existing) return existing - const loaded = import(entrypoint) + const loaded = load() packages.set(entrypoint, loaded) return loaded }, diff --git a/packages/core/src/pty/pty.node.ts b/packages/core/src/pty/pty.node.ts index cc775c8fda..9823b32999 100644 --- a/packages/core/src/pty/pty.node.ts +++ b/packages/core/src/pty/pty.node.ts @@ -1,11 +1,15 @@ -// ast-grep-ignore: no-star-import -import * as pty from "@lydell/node-pty" +import { createRequire } from "node:module" +import { isSea } from "node:sea" import type { Opts, Proc } from "./pty" export type { Disp, Exit, Opts, Proc } from "./pty" +const pty = createRequire(import.meta.url)( + process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty", +) as typeof import("@lydell/node-pty") + export function spawn(file: string, args: string[], opts: Opts): Proc { - const proc = pty.spawn(file, args, opts) + const proc = pty.spawn(file, args, process.platform === "win32" && isSea() ? { ...opts, useConptyDll: true } : opts) return { pid: proc.pid, onData(listener) { diff --git a/packages/core/src/runtime/import.bun.ts b/packages/core/src/runtime/import.bun.ts new file mode 100644 index 0000000000..4e1a98cc44 --- /dev/null +++ b/packages/core/src/runtime/import.bun.ts @@ -0,0 +1,7 @@ +export function importModule(specifier: string) { + return import(specifier) as Promise +} + +export function resolveModule(specifier: string, directory: string) { + return import.meta.resolve(specifier, directory) +} diff --git a/packages/core/src/runtime/import.node.ts b/packages/core/src/runtime/import.node.ts new file mode 100644 index 0000000000..6fa241b84b --- /dev/null +++ b/packages/core/src/runtime/import.node.ts @@ -0,0 +1,39 @@ +import { Script, constants } from "node:vm" +import { createRequire, registerHooks } from "node:module" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { resolve, type Package } from "resolve.exports" + +let conditions: readonly string[] = [] +const conditionHooks = registerHooks({ + resolve(specifier, context, nextResolve) { + conditions = context.conditions + return nextResolve(specifier, context) + }, +}) +await new Script('import("node:module")', { + importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, +}).runInThisContext() +conditionHooks.deregister() + +export async function importModule(specifier: string) { + const imported = (await new Script(`import(${JSON.stringify(specifier)})`, { + importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, + }).runInThisContext()) as unknown + if (typeof imported !== "object" || imported === null) return imported + + const module = imported as Record + const exports = module["module.exports"] + if (exports !== module.default || (typeof exports !== "object" && typeof exports !== "function") || exports === null) + return imported + return Object.assign({}, module, exports) +} + +export function resolveModule(specifier: string, directory: string) { + const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package + const target = resolve(pkg, specifier, { conditions, unsafe: true })?.[0] + if (target) return pathToFileURL(path.resolve(directory, target)).href + const legacyTarget = + specifier === pkg.name ? directory : path.resolve(directory, specifier.slice(pkg.name.length + 1)) + return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(legacyTarget)).href +} diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index a192ba1023..feb06a34d0 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -8,6 +8,7 @@ import { Global } from "../global" import { makeGlobalNode } from "../effect/app-node" import { httpClient } from "../effect/app-node-platform" import { AbsolutePath } from "../schema" +import { Hash } from "../util/hash" const skillConcurrency = 4 const fileConcurrency = 8 @@ -110,7 +111,7 @@ const layer = Layer.effect( ) if (!data) return [] - const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16)) + const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base)) return yield* Effect.forEach( data.skills.flatMap((skill) => { if (!isSafeSegment(skill.name)) { diff --git a/packages/core/src/util/process-lock-ffi.bun.ts b/packages/core/src/util/process-lock-ffi.bun.ts new file mode 100644 index 0000000000..ca821cb6a8 --- /dev/null +++ b/packages/core/src/util/process-lock-ffi.bun.ts @@ -0,0 +1,50 @@ +import { dlopen, read, type Pointer } from "bun:ffi" +import { existsSync } from "node:fs" + +export type LockResult = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +const LOCK_EX = 2 +const LOCK_NB = 4 +const DARWIN_EWOULDBLOCK = 35 +const LINUX_EWOULDBLOCK = 11 + +export function lockDarwin(fd: number): LockResult { + const library = dlopen("/usr/lib/libSystem.B.dylib", { + flock: { args: ["i32", "i32"], returns: "i32" }, + __error: { args: [], returns: "ptr" }, + }) + try { + const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : errorCode(library.symbols.__error()) + if (result === 0) return { acquired: true } + if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.close() + } +} + +export function lockLinux(fd: number): LockResult { + const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1` + const library = dlopen(existsSync(musl) ? musl : "libc.so.6", { + flock: { args: ["i32", "i32"], returns: "i32" }, + __errno_location: { args: [], returns: "ptr" }, + }) + try { + const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location()) + if (result === 0) return { acquired: true } + if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.close() + } +} + +function errorCode(pointer: Pointer | null) { + if (pointer === null) throw new Error("Failed to read process lock error code") + return read.i32(pointer, 0) +} diff --git a/packages/core/src/util/process-lock-ffi.node.ts b/packages/core/src/util/process-lock-ffi.node.ts new file mode 100644 index 0000000000..5416bec635 --- /dev/null +++ b/packages/core/src/util/process-lock-ffi.node.ts @@ -0,0 +1,43 @@ +import { dlopen, getInt32 } from "node:ffi" + +export type LockResult = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +const LOCK_EX = 2 +const LOCK_NB = 4 +const DARWIN_EWOULDBLOCK = 35 +const LINUX_EWOULDBLOCK = 11 + +export function lockDarwin(fd: number): LockResult { + const library = dlopen("/usr/lib/libSystem.B.dylib", { + flock: { arguments: ["int32", "int32"], return: "int32" }, + __error: { arguments: [], return: "pointer" }, + }) + try { + const result = library.functions.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : getInt32(library.functions.__error(), 0) + if (result === 0) return { acquired: true } + if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.lib.close() + } +} + +export function lockLinux(fd: number): LockResult { + const library = dlopen("libc.so.6", { + flock: { arguments: ["int32", "int32"], return: "int32" }, + __errno_location: { arguments: [], return: "pointer" }, + }) + try { + const result = library.functions.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : getInt32(library.functions.__errno_location(), 0) + if (result === 0) return { acquired: true } + if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.lib.close() + } +} diff --git a/packages/core/src/util/process-lock.ts b/packages/core/src/util/process-lock.ts index 045ac9fa05..2c2a721448 100644 --- a/packages/core/src/util/process-lock.ts +++ b/packages/core/src/util/process-lock.ts @@ -1,5 +1,5 @@ -import { dlopen, read, type Pointer } from "bun:ffi" -import { closeSync, existsSync, mkdirSync, openSync } from "node:fs" +import { lockDarwin, lockLinux, type LockResult } from "#process-lock-ffi" +import { closeSync, mkdirSync, openSync } from "node:fs" import { connect, createServer, type Server, type Socket } from "node:net" import path from "node:path" import { Effect, Schema } from "effect" @@ -76,60 +76,12 @@ export namespace ProcessLock { }) } -type Result = - | { readonly acquired: true } - | { readonly acquired: false; readonly held: true } - | { readonly acquired: false; readonly held: false; readonly code: number } - -const LOCK_EX = 2 -const LOCK_NB = 4 -const DARWIN_EWOULDBLOCK = 35 -const LINUX_EWOULDBLOCK = 11 - -function lock(fd: number): Result { +function lock(fd: number): LockResult { if (process.platform === "darwin") return lockDarwin(fd) if (process.platform === "linux") return lockLinux(fd) throw new Error(`Unsupported process lock platform: ${process.platform}`) } -function lockDarwin(fd: number): Result { - const library = dlopen("/usr/lib/libSystem.B.dylib", { - flock: { args: ["i32", "i32"], returns: "i32" }, - __error: { args: [], returns: "ptr" }, - }) - try { - const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) - const code = result === 0 ? 0 : errorCode(library.symbols.__error()) - if (result === 0) return { acquired: true } - if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } - return { acquired: false, held: false, code } - } finally { - library.close() - } -} - -function lockLinux(fd: number): Result { - const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1` - const library = dlopen(existsSync(musl) ? musl : "libc.so.6", { - flock: { args: ["i32", "i32"], returns: "i32" }, - __errno_location: { args: [], returns: "ptr" }, - }) - try { - const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) - const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location()) - if (result === 0) return { acquired: true } - if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } - return { acquired: false, held: false, code } - } finally { - library.close() - } -} - -function errorCode(pointer: Pointer | null) { - if (pointer === null) throw new Error("Failed to read process lock error code") - return read.i32(pointer, 0) -} - function acquireWindows(file: string) { return Effect.callback((resume) => { const server = createServer() diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0dcc506c80..db4de5dba7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -34,9 +34,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.4.3", - "@opentui/keymap": ">=0.4.3", - "@opentui/solid": ">=0.4.3" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/tui/package.json b/packages/tui/package.json index c88246e64b..931c716d95 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -43,6 +43,7 @@ "./util/persistence": "./src/util/persistence.ts", "./util/record": "./src/util/record.ts", "./util/session": "./src/util/session.ts", + "./util/string-width": "./src/util/string-width.ts", "./logo": "./src/logo.ts", "./ui/dialog": "./src/ui/dialog.tsx", "./ui/spinner": "./src/ui/spinner.ts", @@ -50,6 +51,28 @@ "./component/spinner": "./src/component/spinner.tsx", "./component/register-spinner": "./src/component/register-spinner.ts" }, + "imports": { + "#attention-sounds": { + "bun": "./src/attention-sounds.bun.ts", + "node": "./src/attention-sounds.node.ts", + "default": "./src/attention-sounds.bun.ts" + }, + "#terminal-win32": { + "bun": "./src/terminal-win32.bun.ts", + "node": "./src/terminal-win32.node.ts", + "default": "./src/terminal-win32.bun.ts" + }, + "#string-width": { + "bun": "./src/util/string-width.bun.ts", + "node": "./src/util/string-width.node.ts", + "default": "./src/util/string-width.bun.ts" + }, + "#zed-sqlite": { + "bun": "./src/editor-zed-sqlite.bun.ts", + "node": "./src/editor-zed-sqlite.node.ts", + "default": "./src/editor-zed-sqlite.bun.ts" + } + }, "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -64,10 +87,12 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", + "get-east-asian-width": "catalog:", "open": "10.1.2", "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", + "string-width": "catalog:", "strip-ansi": "7.1.2", "uqr": "0.1.3" }, diff --git a/packages/tui/src/attention-sounds.bun.ts b/packages/tui/src/attention-sounds.bun.ts new file mode 100644 index 0000000000..09aa5fe82a --- /dev/null +++ b/packages/tui/src/attention-sounds.bun.ts @@ -0,0 +1,8 @@ +/// +import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } +import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" } +import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" } +import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" } +import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } + +export { defaultSoundPath, questionSoundPath, permissionSoundPath, errorSoundPath, subagentDoneSoundPath } diff --git a/packages/tui/src/attention-sounds.node.ts b/packages/tui/src/attention-sounds.node.ts new file mode 100644 index 0000000000..5169eabfa5 --- /dev/null +++ b/packages/tui/src/attention-sounds.node.ts @@ -0,0 +1,16 @@ +import { createRequire } from "node:module" +import path from "node:path" + +const require = createRequire(import.meta.url) +const resolve = (name: string) => { + const key = `@opencode-ai/ui/audio/${name}` + return process.env.OPENCODE_NODE_ASSETS_DIR + ? path.join(process.env.OPENCODE_NODE_ASSETS_DIR, key) + : require.resolve(key) +} + +export const defaultSoundPath = resolve("bip-bop-01.mp3") +export const questionSoundPath = resolve("bip-bop-03.mp3") +export const permissionSoundPath = resolve("staplebops-06.mp3") +export const errorSoundPath = resolve("nope-03.mp3") +export const subagentDoneSoundPath = resolve("yup-01.mp3") diff --git a/packages/tui/src/attention.ts b/packages/tui/src/attention.ts index e210a745d6..7eac2e0646 100644 --- a/packages/tui/src/attention.ts +++ b/packages/tui/src/attention.ts @@ -14,12 +14,13 @@ import { AttentionSoundName, type Config } from "./config" import { Schema } from "effect" import stripAnsi from "strip-ansi" import * as TuiAudio from "./audio" -import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } -import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" } -import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" } -import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" } -import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } -import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } +import { + defaultSoundPath, + questionSoundPath, + permissionSoundPath, + errorSoundPath, + subagentDoneSoundPath, +} from "#attention-sounds" type FocusState = "unknown" | "focused" | "blurred" @@ -51,7 +52,7 @@ const BUILTIN_PACK: RegisteredSoundPack = { question: questionSoundPath, permission: permissionSoundPath, error: errorSoundPath, - done: doneSoundPath, + done: defaultSoundPath, subagent_done: subagentDoneSoundPath, }, } diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 0d1dd9f340..dd4bc04411 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -1,5 +1,5 @@ import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core" -import { pathToFileURL } from "bun" +import { pathToFileURL } from "node:url" import fuzzysort from "fuzzysort" import path from "path" import { firstBy } from "remeda" @@ -21,6 +21,7 @@ import { useFrecency } from "../../prompt/frecency" import { Keymap } from "../../context/keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/client" +import { stringWidth } from "../../util/string-width" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -188,7 +189,7 @@ export function Autocomplete(props: { const virtualText = "@" + text const extmarkStart = store.index - const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText) + const extmarkEnd = extmarkStart + stringWidth(virtualText) const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId @@ -431,7 +432,7 @@ export function Autocomplete(props: { const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) + props.input().cursorOffset = stringWidth(newText) } const commands = createMemo((): AutocompleteOption[] => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index acffe2904b..4c286eaa43 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -26,6 +26,7 @@ import { editorSelectionKey, useEditorContext, type EditorSelection } from "../. import { normalizePromptContent, openEditor } from "../../editor" import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" +import { stringWidth } from "../../util/string-width" import { createStore, produce, unwrap } from "solid-js/store" import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" @@ -529,7 +530,7 @@ export function Prompt(props: PromptProps) { pasted: [], }) restoreExtmarksFromPrompt(store.prompt) - input.cursorOffset = Bun.stringWidth(normalized) + input.cursorOffset = stringWidth(normalized) }, }, { diff --git a/packages/tui/src/editor-zed-sqlite.bun.ts b/packages/tui/src/editor-zed-sqlite.bun.ts new file mode 100644 index 0000000000..5a2e8ee121 --- /dev/null +++ b/packages/tui/src/editor-zed-sqlite.bun.ts @@ -0,0 +1 @@ +export { Database } from "bun:sqlite" diff --git a/packages/tui/src/editor-zed-sqlite.node.ts b/packages/tui/src/editor-zed-sqlite.node.ts new file mode 100644 index 0000000000..f8ae7a0682 --- /dev/null +++ b/packages/tui/src/editor-zed-sqlite.node.ts @@ -0,0 +1,21 @@ +import { DatabaseSync, type SQLInputValue } from "node:sqlite" + +export class Database { + readonly #database: DatabaseSync + + constructor(file: string, options?: { readonly?: boolean }) { + this.#database = new DatabaseSync(file, { readOnly: options?.readonly }) + } + + query(sql: string) { + const statement = this.#database.prepare(sql) + return { + all: (parameters?: Record) => (parameters ? statement.all(parameters) : statement.all()), + get: (parameters?: Record) => (parameters ? statement.get(parameters) : statement.get()), + } + } + + close() { + this.#database.close() + } +} diff --git a/packages/tui/src/editor-zed.ts b/packages/tui/src/editor-zed.ts index 8ef53ab443..6553978046 100644 --- a/packages/tui/src/editor-zed.ts +++ b/packages/tui/src/editor-zed.ts @@ -1,4 +1,4 @@ -import { Database } from "bun:sqlite" +import { Database } from "#zed-sqlite" import { statSync } from "node:fs" import { readFile as readFileAsync } from "node:fs/promises" import os from "node:os" diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 53daa59366..3ff7b4d433 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -6,6 +6,7 @@ import { useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" +import { stringWidth } from "../../util/string-width" function Directory(props: { context: Plugin.Context; maxWidth: number }) { const { themeV2 } = useTheme() @@ -56,7 +57,7 @@ function View(props: { context: Plugin.Context }) { const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] if (list.length === 0) return 0 const count = list.filter((item) => item.status.status === "connected").length - return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2 + return stringWidth(`⊙ ${count} MCP /status`) + 2 }) return ( @@ -72,7 +73,7 @@ function View(props: { context: Plugin.Context }) { > diff --git a/packages/tui/src/node-ffi.d.ts b/packages/tui/src/node-ffi.d.ts new file mode 100644 index 0000000000..297cd3dadf --- /dev/null +++ b/packages/tui/src/node-ffi.d.ts @@ -0,0 +1,13 @@ +declare module "node:ffi" { + type Signature = { + readonly arguments?: readonly string[] + readonly return?: string + } + + type ForeignFunction = (...args: ReadonlyArray) => number | bigint + + export function dlopen( + path: string, + definitions: Readonly>, + ): { readonly functions: Readonly> } +} diff --git a/packages/tui/src/prompt/display.ts b/packages/tui/src/prompt/display.ts index 4c22942ea8..94bbc7e15f 100644 --- a/packages/tui/src/prompt/display.ts +++ b/packages/tui/src/prompt/display.ts @@ -1,10 +1,12 @@ +import { stringWidth } from "../util/string-width" + const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) export function promptOffsetWidth(value: string) { let width = 0 for (const part of graphemes.segment(value)) { - // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. - width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment) + // Textarea offsets count newlines as one position; terminal width counts them as zero. + width += part.segment === "\n" ? 1 : stringWidth(part.segment) } return width } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ebfee4df2e..c1d9ca7442 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -75,6 +75,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import type { ComponentTheme } from "../../theme/v2/component" +import { stringWidth } from "../../util/string-width" addDefaultParsers(parsers.parsers) @@ -1404,8 +1405,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { return state() ?? "finished" } const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}` - const suffix = () => - Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - Bun.stringWidth(heading()))) + const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading()))) const color = () => { if (state() === "error") return themeV2.text.feedback.error() if (state() === "cancelled") return themeV2.text.feedback.warning() @@ -1561,8 +1561,8 @@ function RevertMessage(props: { 2, ctx.width - 5 - - (file.additions > 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) - - (file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0), + (file.additions > 0 ? stringWidth(`+${file.additions}`) + 1 : 0) - + (file.deletions > 0 ? stringWidth(`-${file.deletions}`) + 1 : 0), )} fg={themeV2.text()} /> @@ -2383,7 +2383,7 @@ function BlockTool(props: { diff --git a/packages/tui/src/terminal-win32.bun.ts b/packages/tui/src/terminal-win32.bun.ts new file mode 100644 index 0000000000..1aaa80aecd --- /dev/null +++ b/packages/tui/src/terminal-win32.bun.ts @@ -0,0 +1,130 @@ +import { dlopen, ptr } from "bun:ffi" +import type { ReadStream } from "node:tty" + +const STD_INPUT_HANDLE = -10 +const ENABLE_PROCESSED_INPUT = 0x0001 + +const kernel = () => + dlopen("kernel32.dll", { + GetStdHandle: { args: ["i32"], returns: "ptr" }, + GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" }, + SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" }, + FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" }, + }) + +let k32: ReturnType | undefined + +function load() { + if (process.platform !== "win32") return false + try { + k32 ??= kernel() + return true + } catch { + return false + } +} + +/** + * Clear ENABLE_PROCESSED_INPUT on the console stdin handle. + */ +export function win32DisableProcessedInput() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const buf = new Uint32Array(1) + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + + const mode = buf[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) +} + +/** + * Discard any queued console input (mouse events, key presses, etc.). + */ +export function win32FlushInputBuffer() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + k32!.symbols.FlushConsoleInputBuffer(handle) +} + +let unhook: (() => void) | undefined + +/** + * Keep ENABLE_PROCESSED_INPUT disabled. + * + * On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when + * ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes + * (sometimes on a later tick), and the flag is console-global, not per-process. + * + * We combine: + * - A `setRawMode(...)` hook to re-clear after known raw-mode toggles. + * - A low-frequency poll as a backstop for native/external mode changes. + */ +export function win32InstallCtrlCGuard() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + if (unhook) return unhook + + const stdin = process.stdin as ReadStream + const original = stdin.setRawMode + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const buf = new Uint32Array(1) + + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + const initial = buf[0]! + + const enforce = () => { + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + const mode = buf[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) + } + + // Some runtimes can re-apply console modes on the next tick; enforce twice. + const later = () => { + enforce() + setImmediate(enforce) + } + + let wrapped: ReadStream["setRawMode"] | undefined + + if (typeof original === "function") { + wrapped = (mode: boolean) => { + const result = original.call(stdin, mode) + later() + return result + } + + stdin.setRawMode = wrapped + } + + // Ensure it's cleared immediately too (covers any earlier mode changes). + later() + + const interval = setInterval(enforce, 100) + interval.unref() + + let done = false + unhook = () => { + if (done) return + done = true + + clearInterval(interval) + if (wrapped && stdin.setRawMode === wrapped) { + stdin.setRawMode = original + } + + k32!.symbols.SetConsoleMode(handle, initial) + unhook = undefined + } + + return unhook +} diff --git a/packages/tui/src/terminal-win32.node.ts b/packages/tui/src/terminal-win32.node.ts new file mode 100644 index 0000000000..11f2f9c993 --- /dev/null +++ b/packages/tui/src/terminal-win32.node.ts @@ -0,0 +1,77 @@ +import { dlopen } from "node:ffi" +import type { ReadStream } from "node:tty" + +const STD_INPUT_HANDLE = -10 +const ENABLE_PROCESSED_INPUT = 0x0001 + +const kernel = () => + dlopen("kernel32.dll", { + GetStdHandle: { arguments: ["i32"], return: "pointer" }, + GetConsoleMode: { arguments: ["pointer", "pointer"], return: "i32" }, + SetConsoleMode: { arguments: ["pointer", "u32"], return: "i32" }, + FlushConsoleInputBuffer: { arguments: ["pointer"], return: "i32" }, + }).functions + +let k32: ReturnType | undefined + +function load() { + if (process.platform !== "win32") return false + try { + k32 ??= kernel() + return true + } catch { + return false + } +} + +export function win32DisableProcessedInput() { + if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return + const handle = k32!.GetStdHandle(STD_INPUT_HANDLE) + const buffer = new Uint32Array(1) + if (k32!.GetConsoleMode(handle, buffer) === 0) return + const mode = buffer[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) +} + +export function win32FlushInputBuffer() { + if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return + k32!.FlushConsoleInputBuffer(k32!.GetStdHandle(STD_INPUT_HANDLE)) +} + +let unhook: (() => void) | undefined + +export function win32InstallCtrlCGuard() { + if (process.platform !== "win32" || !process.stdin.isTTY || !load() || unhook) return unhook + const stdin = process.stdin as ReadStream + const original = stdin.setRawMode + const handle = k32!.GetStdHandle(STD_INPUT_HANDLE) + const buffer = new Uint32Array(1) + if (k32!.GetConsoleMode(handle, buffer) === 0) return + const initial = buffer[0]! + const enforce = () => { + if (k32!.GetConsoleMode(handle, buffer) === 0) return + const mode = buffer[0]! + if ((mode & ENABLE_PROCESSED_INPUT) !== 0) k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) + } + const later = () => { + enforce() + setImmediate(enforce) + } + const wrapped: ReadStream["setRawMode"] = (mode) => { + const result = original.call(stdin, mode) + later() + return result + } + stdin.setRawMode = wrapped + later() + const interval = setInterval(enforce, 100) + interval.unref() + unhook = () => { + clearInterval(interval) + if (stdin.setRawMode === wrapped) stdin.setRawMode = original + k32!.SetConsoleMode(handle, initial) + unhook = undefined + } + return unhook +} diff --git a/packages/tui/src/terminal-win32.ts b/packages/tui/src/terminal-win32.ts index 1aaa80aecd..e58c0e6148 100644 --- a/packages/tui/src/terminal-win32.ts +++ b/packages/tui/src/terminal-win32.ts @@ -1,130 +1 @@ -import { dlopen, ptr } from "bun:ffi" -import type { ReadStream } from "node:tty" - -const STD_INPUT_HANDLE = -10 -const ENABLE_PROCESSED_INPUT = 0x0001 - -const kernel = () => - dlopen("kernel32.dll", { - GetStdHandle: { args: ["i32"], returns: "ptr" }, - GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" }, - SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" }, - FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" }, - }) - -let k32: ReturnType | undefined - -function load() { - if (process.platform !== "win32") return false - try { - k32 ??= kernel() - return true - } catch { - return false - } -} - -/** - * Clear ENABLE_PROCESSED_INPUT on the console stdin handle. - */ -export function win32DisableProcessedInput() { - if (process.platform !== "win32") return - if (!process.stdin.isTTY) return - if (!load()) return - - const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) - const buf = new Uint32Array(1) - if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return - - const mode = buf[0]! - if ((mode & ENABLE_PROCESSED_INPUT) === 0) return - k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) -} - -/** - * Discard any queued console input (mouse events, key presses, etc.). - */ -export function win32FlushInputBuffer() { - if (process.platform !== "win32") return - if (!process.stdin.isTTY) return - if (!load()) return - - const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) - k32!.symbols.FlushConsoleInputBuffer(handle) -} - -let unhook: (() => void) | undefined - -/** - * Keep ENABLE_PROCESSED_INPUT disabled. - * - * On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when - * ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes - * (sometimes on a later tick), and the flag is console-global, not per-process. - * - * We combine: - * - A `setRawMode(...)` hook to re-clear after known raw-mode toggles. - * - A low-frequency poll as a backstop for native/external mode changes. - */ -export function win32InstallCtrlCGuard() { - if (process.platform !== "win32") return - if (!process.stdin.isTTY) return - if (!load()) return - if (unhook) return unhook - - const stdin = process.stdin as ReadStream - const original = stdin.setRawMode - - const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) - const buf = new Uint32Array(1) - - if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return - const initial = buf[0]! - - const enforce = () => { - if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return - const mode = buf[0]! - if ((mode & ENABLE_PROCESSED_INPUT) === 0) return - k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) - } - - // Some runtimes can re-apply console modes on the next tick; enforce twice. - const later = () => { - enforce() - setImmediate(enforce) - } - - let wrapped: ReadStream["setRawMode"] | undefined - - if (typeof original === "function") { - wrapped = (mode: boolean) => { - const result = original.call(stdin, mode) - later() - return result - } - - stdin.setRawMode = wrapped - } - - // Ensure it's cleared immediately too (covers any earlier mode changes). - later() - - const interval = setInterval(enforce, 100) - interval.unref() - - let done = false - unhook = () => { - if (done) return - done = true - - clearInterval(interval) - if (wrapped && stdin.setRawMode === wrapped) { - stdin.setRawMode = original - } - - k32!.symbols.SetConsoleMode(handle, initial) - unhook = undefined - } - - return unhook -} +export { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "#terminal-win32" diff --git a/packages/tui/src/ui/file-path.tsx b/packages/tui/src/ui/file-path.tsx index 0633e2aef4..c9539dd828 100644 --- a/packages/tui/src/ui/file-path.tsx +++ b/packages/tui/src/ui/file-path.tsx @@ -1,5 +1,6 @@ import type { RGBA } from "@opentui/core" import { createMemo } from "solid-js" +import { stringWidth } from "../util/string-width" const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) @@ -29,7 +30,7 @@ export function FilePath(props: FilePathProps) { export function truncateFilePath(value: string, maxWidth: number) { if (maxWidth <= 0) return "" - if (Bun.stringWidth(value) <= maxWidth) return value + if (stringWidth(value) <= maxWidth) return value const drive = value.match(/^([A-Za-z]:)([\\/])/) const unc = value.match(/^(\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/) @@ -46,22 +47,22 @@ export function truncateFilePath(value: string, maxWidth: number) { const segments = source.split(windows ? /[\\/]/ : separator).filter(Boolean) const basename = segments.at(-1) ?? value if (segments.length < 2) { - const rootWidth = Bun.stringWidth(root) + const rootWidth = stringWidth(root) if (rootWidth >= maxWidth) return takeStart(root, maxWidth) return root + truncateBasename(basename, maxWidth - rootWidth) } const prefix = `${root}…${separator}` - const basenameWidth = maxWidth - Bun.stringWidth(prefix) + const basenameWidth = maxWidth - stringWidth(prefix) if (basenameWidth <= 0) return takeStart(prefix, maxWidth) const compact = truncateBasename(basename, basenameWidth) if (compact !== basename) return prefix + compact const selected = [basename] - const separatorWidth = Bun.stringWidth(separator) - let width = Bun.stringWidth(prefix + basename) + const separatorWidth = stringWidth(separator) + let width = stringWidth(prefix + basename) for (let index = segments.length - 2; index >= 0; index--) { - const next = Bun.stringWidth(segments[index]!) + separatorWidth + const next = stringWidth(segments[index]!) + separatorWidth if (width + next > maxWidth) break selected.unshift(segments[index]!) width += next @@ -70,12 +71,12 @@ export function truncateFilePath(value: string, maxWidth: number) { } function truncateBasename(value: string, maxWidth: number) { - if (Bun.stringWidth(value) <= maxWidth) return value + if (stringWidth(value) <= maxWidth) return value if (maxWidth <= 1) return takeStart("…", maxWidth) const dot = value.lastIndexOf(".") const extension = dot > 0 ? value.slice(dot) : "" - const extensionWidth = Bun.stringWidth(extension) + const extensionWidth = stringWidth(extension) if (extensionWidth >= maxWidth) return "…" + takeEnd(extension, maxWidth - 1) const stem = extension ? value.slice(0, dot) : value @@ -96,7 +97,7 @@ function take(value: string, maxWidth: number, reverse: boolean) { const selected: string[] = [] let width = 0 for (const segment of segments) { - const next = Bun.stringWidth(segment) + const next = stringWidth(segment) if (width + next > maxWidth) break selected.push(segment) width += next diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts index 79537410f5..06d0b45077 100644 --- a/packages/tui/src/util/locale.ts +++ b/packages/tui/src/util/locale.ts @@ -1,3 +1,5 @@ +import { stringWidth } from "./string-width" + export function titlecase(str: string) { return str.replace(/\b\w/g, (c) => c.toUpperCase()) } @@ -67,13 +69,13 @@ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme export function truncateWidth(str: string, width: number): string { if (width <= 0) return "" - if (Bun.stringWidth(str) <= width) return str + if (stringWidth(str) <= width) return str if (width === 1) return "…" const result: string[] = [] let used = 0 for (const item of graphemeSegmenter.segment(str)) { - const next = Bun.stringWidth(item.segment) + const next = stringWidth(item.segment) if (used + next > width - 1) break result.push(item.segment) used += next diff --git a/packages/tui/src/util/persistence.ts b/packages/tui/src/util/persistence.ts index 1f7c283ca1..d013628218 100644 --- a/packages/tui/src/util/persistence.ts +++ b/packages/tui/src/util/persistence.ts @@ -1,17 +1,17 @@ import path from "path" -import { appendFile, mkdir, rename, rm } from "fs/promises" +import { appendFile, mkdir, readFile, rename, rm, writeFile } from "fs/promises" export function readText(filePath: string) { - return Bun.file(filePath).text() + return readFile(filePath, "utf8") } -export function readJson(filePath: string) { - return Bun.file(filePath).json() as Promise +export async function readJson(filePath: string) { + return JSON.parse(await readFile(filePath, "utf8")) as T } export async function writeText(filePath: string, content: string) { await mkdir(path.dirname(filePath), { recursive: true }) - await Bun.write(filePath, content) + await writeFile(filePath, content) } export async function appendText(filePath: string, content: string) { @@ -22,7 +22,7 @@ export async function appendText(filePath: string, content: string) { export async function writeJsonAtomic(filePath: string, value: unknown) { await mkdir(path.dirname(filePath), { recursive: true }) const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp` - await Bun.write(temporary, JSON.stringify(value)).catch(async (error) => { + await writeFile(temporary, JSON.stringify(value)).catch(async (error) => { await rm(temporary, { force: true }).catch(() => undefined) throw error }) diff --git a/packages/tui/src/util/string-width.bun.ts b/packages/tui/src/util/string-width.bun.ts new file mode 100644 index 0000000000..6e3df4cda8 --- /dev/null +++ b/packages/tui/src/util/string-width.bun.ts @@ -0,0 +1 @@ +export const stringWidth = Bun.stringWidth diff --git a/packages/tui/src/util/string-width.node.ts b/packages/tui/src/util/string-width.node.ts new file mode 100644 index 0000000000..10d7299d18 --- /dev/null +++ b/packages/tui/src/util/string-width.node.ts @@ -0,0 +1,26 @@ +import measure from "string-width" +import stripAnsi from "strip-ansi" +import { eastAsianWidth } from "get-east-asian-width" + +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) +const textEmoji = /^\p{Emoji}\p{Mark}*$/u +const emojiPresentation = /^\p{Emoji_Presentation}/u + +export function stringWidth(value: string) { + return Array.from(graphemes.segment(stripAnsi(value))).reduce((total, part) => { + const width = measure(part.segment) + const codePoint = part.segment.codePointAt(0) + if ( + width !== 2 || + codePoint === undefined || + eastAsianWidth(codePoint) === 2 || + !textEmoji.test(part.segment) || + emojiPresentation.test(part.segment) || + part.segment.includes("\uFE0F") || + part.segment.includes("\u20E3") || + part.segment.includes("\u200D") + ) + return total + width + return total + 1 + }, 0) +} diff --git a/packages/tui/src/util/string-width.ts b/packages/tui/src/util/string-width.ts new file mode 100644 index 0000000000..09c1ffd392 --- /dev/null +++ b/packages/tui/src/util/string-width.ts @@ -0,0 +1 @@ +export { stringWidth } from "#string-width" diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index 6a7cb174a9..172dadee2a 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -170,7 +170,7 @@ async function renderOnceSettled(app: Awaited>) { async function captureSettledFrame(app: Awaited>) { for (let attempt = 0; attempt < 5; attempt++) { const frame = app.captureCharFrame() - if (frame.trim().length > 0) return frame + if (visibleLines(frame).length > 0) return frame await new Promise((resolve) => setTimeout(resolve, 25)) await app.renderOnce() }