diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9d29724d..36b46831 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -8,6 +8,17 @@ inputs: runs: using: "composite" steps: + - name: Initialize setup diagnostics + shell: bash + run: | + PACKAGE_MANAGER=$(node -p "require('./package.json').packageManager" 2>/dev/null || true) + { + echo "runner_os=$RUNNER_OS" + echo "runner_arch=$RUNNER_ARCH" + echo "node=$(node --version 2>/dev/null || true)" + echo "package_manager=$PACKAGE_MANAGER" + } > setup-bun.log + # node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22; # some runner images ship an older system Node on PATH - name: Setup Node @@ -26,7 +37,9 @@ runs: Linux) OS=linux ;; Windows) OS=windows ;; esac - echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT" + URL="https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "bun_download_url=$URL" >> setup-bun.log fi - name: Setup Bun @@ -35,10 +48,19 @@ runs: bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-download-url: ${{ steps.bun-url.outputs.url }} + - name: Record Bun version + shell: bash + run: | + echo "bun=$(bun --version)" >> setup-bun.log + echo "bun_revision=$(bun --revision 2>/dev/null || true)" >> setup-bun.log + - name: Get cache directory id: cache shell: bash - run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" + run: | + DIR=$(bun pm cache) + echo "dir=$DIR" >> "$GITHUB_OUTPUT" + echo "bun_cache=$DIR" >> setup-bun.log - name: Restore Bun dependencies id: bun-cache @@ -55,14 +77,19 @@ runs: - name: Install dependencies run: | - # Workaround for patched peer variants - # e.g. ./patches/ for standard-openapi - # https://github.com/oven-sh/bun/issues/28147 - if [ "$RUNNER_OS" = "Windows" ]; then - bun install --linker hoisted ${{ inputs.install-flags }} - else - bun install ${{ inputs.install-flags }} - fi + set -o pipefail + { + echo "== bun install ==" + # Workaround for patched peer variants, for example ./patches/standard-openapi. + if [ "$RUNNER_OS" = "Windows" ]; then + bun install --linker hoisted ${{ inputs.install-flags }} + else + bun install ${{ inputs.install-flags }} + fi + echo "== repository diff after install ==" + git status --short + git diff -- bun.lock package.json packages/opencode/package.json + } 2>&1 | tee -a setup-bun.log shell: bash - name: Save Bun dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8e82f86..dee38def 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,25 +85,25 @@ jobs: env: DEEPCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} - - name: Run S1-02-D installation lifecycle tests + - name: Run installation lifecycle tests working-directory: packages/opencode run: | set -o pipefail bun test test/installation --timeout 30000 2>&1 | tee installation-${{ matrix.settings.name }}.log - - name: Run S1-02-E Core isolation tests + - name: Run Core isolation tests working-directory: packages/core run: | set -o pipefail bun test test/global.test.ts test/config.test.ts --timeout 30000 2>&1 | tee boundary-core-${{ matrix.settings.name }}.log - - name: Run S1-02-E full config and permission regression + - name: Run full config and permission regression working-directory: packages/opencode run: | set -o pipefail bun test test/config test/effect/runtime-flags-deepcode.test.ts test/permission --timeout 30000 2>&1 | tee boundary-opencode-${{ matrix.settings.name }}.log - - name: Capture S1-02-E OpenCode string inventory + - name: Capture OpenCode string inventory if: runner.os == 'Linux' && always() run: | status=0 @@ -113,7 +113,7 @@ jobs: fi cat opencode-boundary-audit.log - - name: Upload unit and boundary logs + - name: Upload unit, boundary, and setup logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -121,6 +121,7 @@ jobs: if-no-files-found: error retention-days: 7 path: | + setup-bun.log unit-${{ matrix.settings.name }}.log packages/opencode/installation-${{ matrix.settings.name }}.log packages/core/boundary-core-${{ matrix.settings.name }}.log @@ -208,13 +209,14 @@ jobs: CI: true timeout-minutes: 30 - - name: Upload Playwright artifacts + - name: Upload Playwright and setup artifacts if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} - if-no-files-found: ignore + if-no-files-found: error retention-days: 7 path: | + setup-bun.log packages/app/e2e/test-results packages/app/e2e/playwright-report diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9178ad38..7aa0b24d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -7,17 +7,28 @@ on: branches: [develop, master] workflow_dispatch: +permissions: + contents: read + jobs: typecheck: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + show-progress: false - name: Setup Bun uses: ./.github/actions/setup-bun - - name: Run S1-02 package typechecks + - name: Verify generated dependency metadata is clean + shell: bash + run: | + set -euo pipefail + git diff --exit-code -- bun.lock package.json packages/opencode/package.json packages/opencode/bin/deepcode packages/opencode/script/install.sh + + - name: Run package typechecks run: | set -euo pipefail { @@ -27,11 +38,13 @@ jobs: bun --cwd packages/opencode typecheck } 2>&1 | tee typecheck.log - - name: Upload typecheck log + - name: Upload typecheck and setup evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: typecheck-${{ github.run_attempt }} if-no-files-found: error retention-days: 7 - path: typecheck.log + path: | + setup-bun.log + typecheck.log diff --git a/bun.lock b/bun.lock index 756c3ce2..3f678447 100644 --- a/bun.lock +++ b/bun.lock @@ -569,11 +569,18 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/oh-my-deepagent": { + "name": "@deepcode/oh-my-deepagent", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "1.3.13", + }, + }, "packages/opencode": { - "name": "opencode", + "name": "deepcode", "version": "1.17.13", "bin": { - "opencode": "./bin/opencode", + "deepcode": "./bin/deepcode", }, "dependencies": { "@actions/core": "1.11.1", @@ -1064,7 +1071,7 @@ "devDependencies": { "@astrojs/check": "0.9.6", "@types/node": "catalog:", - "opencode": "workspace:*", + "deepcode": "workspace:*", "typescript": "catalog:", }, }, @@ -1490,6 +1497,8 @@ "@deepcode/gateway": ["@deepcode/gateway@workspace:packages/deepcode-gateway"], + "@deepcode/oh-my-deepagent": ["@deepcode/oh-my-deepagent@workspace:packages/oh-my-deepagent"], + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "https://registry.npmmirror.com/@dnd-kit/abstract/-/abstract-0.5.0.tgz", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], @@ -3476,6 +3485,8 @@ "deep-eql": ["deep-eql@5.0.2", "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deepcode": ["deepcode@workspace:packages/opencode"], + "deepmerge": ["deepmerge@4.3.1", "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.0", "https://registry.npmmirror.com/default-browser/-/default-browser-5.5.0.tgz", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -4634,8 +4645,6 @@ "openapi-types": ["openapi-types@12.1.3", "https://registry.npmmirror.com/openapi-types/-/openapi-types-12.1.3.tgz", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "opencode": ["opencode@workspace:packages/opencode"], - "opencode-gitlab-auth": ["opencode-gitlab-auth@2.1.0", "https://registry.npmmirror.com/opencode-gitlab-auth/-/opencode-gitlab-auth-2.1.0.tgz", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-ZCDYaY0V8Se6hOH2tqZqqcskrd0xLTgfiGhU0J1igkUP52oFtN9eSwxOPLT0ctvNXUq8b+zOmJ4sskAQoC/IUA=="], "opencode-poe-auth": ["opencode-poe-auth@0.0.1", "https://registry.npmmirror.com/opencode-poe-auth/-/opencode-poe-auth-0.0.1.tgz", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="], @@ -6246,6 +6255,18 @@ "cross-spawn/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "deepcode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "https://registry.npmmirror.com/@ai-sdk/cerebras/-/cerebras-2.0.60.tgz", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], + + "deepcode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-3.0.53.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + + "deepcode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-2.0.41.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + + "deepcode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "https://registry.npmmirror.com/@solid-primitives/scheduled/-/scheduled-1.5.2.tgz", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + + "deepcode/minimatch": ["minimatch@10.0.3", "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.3.tgz", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + + "deepcode/semver": ["semver@7.8.5", "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "dir-compare/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -6390,18 +6411,6 @@ "nypm/tinyexec": ["tinyexec@1.2.4", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "https://registry.npmmirror.com/@ai-sdk/cerebras/-/cerebras-2.0.60.tgz", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-3.0.53.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], - - "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-2.0.41.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], - - "opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "https://registry.npmmirror.com/@solid-primitives/scheduled/-/scheduled-1.5.2.tgz", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], - - "opencode/minimatch": ["minimatch@10.0.3", "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.3.tgz", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], - - "opencode/semver": ["semver@7.8.5", "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "opencode-gitlab-auth/open": ["open@10.2.0", "https://registry.npmmirror.com/open/-/open-10.2.0.tgz", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "openid-client/jose": ["jose@4.15.9", "https://registry.npmmirror.com/jose/-/jose-4.15.9.tgz", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], @@ -7036,6 +7045,12 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "deepcode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-2.0.54.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="], + + "deepcode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.12.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + + "deepcode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-4.0.33.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -7118,12 +7133,6 @@ "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-2.0.54.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="], - - "opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.12.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], - - "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-4.0.33.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7416,6 +7425,8 @@ "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "deepcode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7462,8 +7473,6 @@ "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-3.0.0.tgz", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/packages/opencode/bin/deepcode b/packages/opencode/bin/deepcode new file mode 100755 index 00000000..d260dea4 --- /dev/null +++ b/packages/opencode/bin/deepcode @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +const childProcess = require("child_process") +const fs = require("fs") +const os = require("os") +const path = require("path") + +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] + +function run(target) { + const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" }) + child.on("error", (error) => { + console.error(error.message) + process.exit(1) + }) + + const forwarders = {} + for (const signal of forwardedSignals) { + forwarders[signal] = () => { + try { + child.kill(signal) + } catch { + // The child may already have exited. + } + } + process.on(signal, forwarders[signal]) + } + + child.on("exit", (code, signal) => { + for (const forwardedSignal of forwardedSignals) { + process.removeListener(forwardedSignal, forwarders[forwardedSignal]) + } + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(typeof code === "number" ? code : 0) + }) +} + +const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" } +const architectureMap = { x64: "x64", arm64: "arm64" } +const platform = platformMap[os.platform()] +const architecture = architectureMap[os.arch()] + +if (!platform || !architecture) { + console.error(`DeepCode does not publish a CLI artifact for ${os.platform()}-${os.arch()}.`) + process.exit(1) +} + +const scriptPath = fs.realpathSync(__filename) +const scriptDir = path.dirname(scriptPath) +const packageName = `deepcode-${platform}-${architecture}` +const binaryName = platform === "windows" ? "deepcode.exe" : "deepcode" +const cached = path.join(scriptDir, ".deepcode") + +function findBinary(startDir) { + let current = startDir + for (;;) { + const candidate = path.join(current, "node_modules", packageName, "bin", binaryName) + if (fs.existsSync(candidate)) return candidate + const parent = path.dirname(current) + if (parent === current) return + current = parent + } +} + +const resolved = process.env.DEEPCODE_BIN_PATH || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) +if (!resolved) { + console.error( + "DeepCode package-manager installation is not supported in Alpha. Install the verified GitHub Release through the official curl installer.", + ) + process.exit(1) +} + +run(resolved) diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode deleted file mode 100755 index a7101f42..00000000 --- a/packages/opencode/bin/opencode +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env node - -const childProcess = require("child_process") -const fs = require("fs") -const path = require("path") -const os = require("os") - -const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] - -function run(target) { - const child = childProcess.spawn(target, process.argv.slice(2), { - stdio: "inherit", - }) - - child.on("error", (error) => { - console.error(error.message) - process.exit(1) - }) - - const forwarders = {} - for (const signal of forwardedSignals) { - forwarders[signal] = () => { - try { - child.kill(signal) - } catch { - // The child may have already exited. - } - } - process.on(signal, forwarders[signal]) - } - - child.on("exit", (code, signal) => { - for (const forwardedSignal of forwardedSignals) { - process.removeListener(forwardedSignal, forwarders[forwardedSignal]) - } - - if (signal) { - process.kill(process.pid, signal) - return - } - - process.exit(typeof code === "number" ? code : 0) - }) -} - -const envPath = process.env.OPENCODE_BIN_PATH - -const scriptPath = fs.realpathSync(__filename) -const scriptDir = path.dirname(scriptPath) - -// -const cached = path.join(scriptDir, ".opencode") - -const platformMap = { - darwin: "darwin", - linux: "linux", - win32: "windows", -} -const archMap = { - x64: "x64", - arm64: "arm64", - arm: "arm", -} - -let platform = platformMap[os.platform()] -if (!platform) { - platform = os.platform() -} -let arch = archMap[os.arch()] -if (!arch) { - arch = os.arch() -} -const base = "opencode-" + platform + "-" + arch -const binary = platform === "windows" ? "opencode.exe" : "opencode" - -function supportsAvx2() { - if (arch !== "x64") return false - - if (platform === "linux") { - try { - return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) - } catch { - return false - } - } - - if (platform === "darwin") { - try { - const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { - encoding: "utf8", - timeout: 1500, - }) - if (result.status !== 0) return false - return (result.stdout || "").trim() === "1" - } catch { - return false - } - } - - if (platform === "windows") { - const cmd = - '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' - - for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { - try { - const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], { - encoding: "utf8", - timeout: 3000, - windowsHide: true, - }) - if (result.status !== 0) continue - const out = (result.stdout || "").trim().toLowerCase() - if (out === "true" || out === "1") return true - if (out === "false" || out === "0") return false - } catch { - continue - } - } - - return false - } - - return false -} - -const names = (() => { - const avx2 = supportsAvx2() - const baseline = arch === "x64" && !avx2 - - if (platform === "linux") { - const musl = (() => { - try { - if (fs.existsSync("/etc/alpine-release")) return true - } catch { - // ignore - } - - try { - const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) - const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() - if (text.includes("musl")) return true - } catch { - // ignore - } - - return false - })() - - if (musl) { - if (arch === "x64") { - if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] - return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] - } - return [`${base}-musl`, base] - } - - if (arch === "x64") { - if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] - return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] - } - return [base, `${base}-musl`] - } - - if (arch === "x64") { - if (baseline) return [`${base}-baseline`, base] - return [base, `${base}-baseline`] - } - return [base] -})() - -function findBinary(startDir) { - let current = startDir - for (;;) { - const modules = path.join(current, "node_modules") - if (fs.existsSync(modules)) { - for (const name of names) { - const candidate = path.join(modules, name, "bin", binary) - if (fs.existsSync(candidate)) return candidate - } - } - const parent = path.dirname(current) - if (parent === current) { - return - } - current = parent - } -} - -const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) -if (!resolved) { - console.error( - "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " + - names.map((n) => `\"${n}\"`).join(" or ") + - " package", - ) - process.exit(1) -} - -run(resolved) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2eae4878..d4f0669f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -16,7 +16,7 @@ "dev:temporary": "bun run --conditions=browser ./src/temporary.ts" }, "bin": { - "deepcode": "./bin/opencode" + "deepcode": "./bin/deepcode" }, "exports": { "./*": "./src/*.ts" diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 236838db..9a8d46a2 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -18,14 +18,13 @@ import { Script } from "@opencode-ai/script" import pkg from "../package.json" const singleFlag = process.argv.includes("--single") -const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") const createEmbeddedWebUIBundle = async () => { - console.log(`Building Web UI to embed in the binary`) + console.log("Building Web UI to embed in the binary") const appDir = path.join(import.meta.dirname, "../../app") const dist = path.join(appDir, "dist") await $`OPENCODE_CHANNEL=${Script.channel} bun run --cwd ${appDir} build` @@ -51,87 +50,20 @@ const createEmbeddedWebUIBundle = async () => { const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle() const allTargets: { - os: string + os: "linux" | "darwin" | "win32" arch: "arm64" | "x64" - abi?: "musl" avx2?: false }[] = [ - { - os: "linux", - arch: "arm64", - }, - { - os: "linux", - arch: "x64", - }, - { - os: "linux", - arch: "x64", - avx2: false, - }, - { - os: "linux", - arch: "arm64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - avx2: false, - }, - { - os: "darwin", - arch: "arm64", - }, - { - os: "darwin", - arch: "x64", - }, - { - os: "darwin", - arch: "x64", - avx2: false, - }, - { - os: "win32", - arch: "arm64", - }, - { - os: "win32", - arch: "x64", - }, - { - os: "win32", - arch: "x64", - avx2: false, - }, + { os: "linux", arch: "arm64" }, + { os: "linux", arch: "x64", avx2: false }, + { os: "darwin", arch: "arm64" }, + { os: "darwin", arch: "x64", avx2: false }, + { os: "win32", arch: "arm64" }, + { os: "win32", arch: "x64", avx2: false }, ] const targets = singleFlag - ? allTargets.filter((item) => { - if (item.os !== process.platform || item.arch !== process.arch) { - return false - } - - // When building for the current platform, prefer a single native binary by default. - // Baseline binaries require additional Bun artifacts and can be flaky to download. - if (item.avx2 === false) { - return baselineFlag - } - - // also skip abi-specific builds for the same reason - if (item.abi !== undefined) { - return false - } - - return true - }) + ? allTargets.filter((item) => item.os === process.platform && item.arch === process.arch) : allTargets await $`rm -rf dist` @@ -142,17 +74,11 @@ if (!skipInstall) { await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}` await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${pkg.dependencies["@ff-labs/fff-bun"]}` } + for (const item of targets) { - const name = [ - pkg.name, - // changing to win32 flags npm for some reason - item.os === "win32" ? "windows" : item.os, - item.arch, - item.avx2 === false ? "baseline" : undefined, - item.abi === undefined ? undefined : item.abi, - ] - .filter(Boolean) - .join("-") + const platform = item.os === "win32" ? "windows" : item.os + const name = `${pkg.name}-${platform}-${item.arch}` + const target = ["bun", platform, item.arch, item.avx2 === false ? "baseline" : undefined].filter(Boolean).join("-") console.log(`building ${name}`) await $`mkdir -p dist/${name}/bin` @@ -160,10 +86,9 @@ for (const item of targets) { const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath) const workerPath = "./src/cli/tui/worker.ts" - - // Use platform-specific bunfs root path based on target OS const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/") + const binaryPath = `dist/${name}/bin/${pkg.name}${item.os === "win32" ? ".exe" : ""}` await Bun.build({ conditions: ["bun", "node"], @@ -179,36 +104,32 @@ for (const item of targets) { autoloadDotenv: false, autoloadTsconfig: true, autoloadPackageJson: true, - target: name.replace(pkg.name, "bun") as any, - outfile: `dist/${name}/bin/opencode`, - execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], + target: target as any, + outfile: binaryPath, + execArgv: [`--user-agent=${pkg.name}/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { - FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"), + FFF_LIBC: JSON.stringify("gnu"), OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_MODELS_DEV: generated.modelsData, OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, OPENCODE_WORKER_PATH: workerPath, OPENCODE_CHANNEL: `'${Script.channel}'`, - OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", - ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), + OPENCODE_LIBC: item.os === "linux" ? `'glibc'` : "", + ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify("glibc") } : {}), }, }) - // Smoke test: only run if binary is for current platform - if (item.os === process.platform && item.arch === process.arch && !item.abi) { - const binaryPath = `dist/${name}/bin/opencode` + if (item.os === process.platform && item.arch === process.arch) { console.log(`Running smoke test: ${binaryPath} --version`) - try { - const versionOutput = await $`${binaryPath} --version`.text() - console.log(`Smoke test passed: ${versionOutput.trim()}`) - } catch (e) { - console.error(`Smoke test failed for ${name}:`, e) + const versionOutput = await $`${binaryPath} --version`.text().catch((error) => { + console.error(`Smoke test failed for ${name}:`, error) process.exit(1) - } + }) + console.log(`Smoke test passed: ${versionOutput.trim()}`) } await $`rm -rf ./dist/${name}/bin/tui` @@ -220,7 +141,6 @@ for (const item of targets) { preferUnplugged: true, os: [item.os], cpu: [item.arch], - ...(item.abi ? { libc: [item.abi] } : {}), }, null, 2, @@ -230,14 +150,33 @@ for (const item of targets) { } if (Script.release) { - for (const key of Object.keys(binaries)) { - if (key.includes("linux")) { - await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`) - } else { - await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`) - } + const repository = process.env.GH_REPO + if (repository !== "yuanchenglu/deepcode") { + throw new Error(`Refusing to publish DeepCode CLI artifacts to unverified repository: ${repository ?? "missing"}`) + } + + const assets: { name: string; sha256: string }[] = [] + for (const key of Object.keys(binaries).sort()) { + const extension = key.includes("linux") ? "tar.gz" : "zip" + const file = `${key}.${extension}` + if (extension === "tar.gz") await $`tar -czf ../../${file} *`.cwd(`dist/${key}/bin`) + else await $`zip -r ../../${file} *`.cwd(`dist/${key}/bin`) + assets.push({ name: file, sha256: await hashFile(`dist/${file}`) }) } - await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}` + + await Bun.file("dist/deepcode-manifest.json").write( + `${JSON.stringify({ schema: 1, product: "deepcode", version: Script.version, assets }, null, 2)}\n`, + ) + await Bun.file("dist/deepcode-checksums.txt").write( + `${assets.map((asset) => `${asset.sha256} ${asset.name}`).join("\n")}\n`, + ) + await $`gh release upload v${Script.version} ./dist/deepcode-*.zip ./dist/deepcode-*.tar.gz ./dist/deepcode-manifest.json ./dist/deepcode-checksums.txt --clobber --repo ${repository}` +} + +async function hashFile(file: string) { + const hasher = new Bun.CryptoHasher("sha256") + hasher.update(new Uint8Array(await Bun.file(file).arrayBuffer())) + return hasher.digest("hex") } export { binaries } diff --git a/packages/opencode/script/install.sh b/packages/opencode/script/install.sh new file mode 100755 index 00000000..fabbd2d4 --- /dev/null +++ b/packages/opencode/script/install.sh @@ -0,0 +1,160 @@ +#!/bin/sh +set -eu + +repo="yuanchenglu/deepcode" +api="https://api.github.com/repos/$repo" +release_base="https://github.com/$repo/releases/download" +install_root="${HOME}/.deepcode" +bin_dir="$install_root/bin" +binary="$bin_dir/deepcode" +manifest="$install_root/install-manifest.json" +version="${DEEPCODE_VERSION:-}" + +fail() { + printf '%s\n' "DeepCode install failed: $*" >&2 + exit 1 +} + +command -v curl >/dev/null 2>&1 || fail "curl is required" + +if [ -z "$version" ]; then + version=$(curl -fsSL -H 'Accept: application/vnd.github+json' "$api/releases/latest" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' \ + | head -n 1) +fi +printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$' || fail "invalid release version: $version" + +case "$(uname -s)" in + Darwin) platform="darwin"; extension="zip" ;; + Linux) platform="linux"; extension="tar.gz" ;; + *) fail "unsupported operating system: $(uname -s)" ;; +esac + +case "$(uname -m)" in + arm64|aarch64) architecture="arm64" ;; + x86_64|amd64) architecture="x64" ;; + *) fail "unsupported architecture: $(uname -m)" ;; +esac + +asset="deepcode-${platform}-${architecture}.${extension}" +base="$release_base/v$version" +temporary=$(mktemp -d "${TMPDIR:-/tmp}/deepcode-install.XXXXXX") +trap 'rm -rf "$temporary"' EXIT INT TERM + +if [ -x "$binary" ] && "$binary" --version 2>/dev/null | grep -Fq "$version"; then + mkdir -p "$install_root" + cat > "$manifest.tmp" </dev/null 2>&1; then + actual=$(sha256sum "$temporary/$asset" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$temporary/$asset" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required" +fi +[ "$actual" = "$expected" ] || fail "checksum mismatch for $asset" + +mkdir -p "$temporary/extract" +if [ "$platform" = "linux" ]; then + tar -xzf "$temporary/$asset" -C "$temporary/extract" || fail "archive extraction failed" +else + command -v unzip >/dev/null 2>&1 || fail "unzip is required on macOS" + unzip -q "$temporary/$asset" -d "$temporary/extract" || fail "archive extraction failed" +fi + +candidate="$temporary/extract/deepcode" +[ -f "$candidate" ] && [ ! -L "$candidate" ] || fail "archive does not contain the native deepcode binary" +[ "$(find "$temporary/extract" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" = "1" ] \ + || fail "archive contains unexpected files" +chmod 755 "$candidate" +"$candidate" --version 2>/dev/null | grep -Fq "$version" || fail "binary smoke test or version verification failed" + +mkdir -p "$bin_dir" +staged="$bin_dir/.deepcode.new" +backup="$binary.previous" +rm -f "$staged" +cp "$candidate" "$staged" +chmod 755 "$staged" + +replaced=0 +rollback() { + if [ "$replaced" = "1" ] && [ -f "$backup" ]; then + rm -f "$binary" + mv "$backup" "$binary" + fi +} +trap 'rollback; rm -rf "$temporary"' EXIT INT TERM + +rm -f "$backup" +if [ -e "$binary" ]; then + mv "$binary" "$backup" + replaced=1 +fi +mv "$staged" "$binary" +"$binary" --version 2>/dev/null | grep -Fq "$version" || fail "installed binary verification failed" + +cat > "$manifest.tmp" <> "$shell_config" +fi + +replaced=0 +trap 'rm -rf "$temporary"' EXIT INT TERM +printf '%s\n' "DeepCode $version installed at $binary" diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index fa303b74..19a8455e 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -1,189 +1,10 @@ #!/usr/bin/env node -import childProcess from "child_process" -import fs from "fs" -import os from "os" -import path from "path" -import { createRequire } from "module" -import { fileURLToPath } from "url" - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const require = createRequire(import.meta.url) -const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")) - -const platformMap = { - darwin: "darwin", - linux: "linux", - win32: "windows", -} -const archMap = { - x64: "x64", - arm64: "arm64", - arm: "arm", -} - -const platform = platformMap[os.platform()] ?? os.platform() -const arch = archMap[os.arch()] ?? os.arch() -const base = `opencode-${platform}-${arch}` -const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode" -const targetBinary = path.join(__dirname, "bin", "opencode.exe") - -function supportsAvx2() { - if (arch !== "x64") return false - - if (platform === "linux") { - try { - return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) - } catch { - return false - } - } - - if (platform === "darwin") { - try { - const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { - encoding: "utf8", - timeout: 1500, - }) - if (result.status !== 0) return false - return (result.stdout || "").trim() === "1" - } catch { - return false - } - } - - if (platform === "windows") { - const command = - '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' - - for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { - try { - const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { - encoding: "utf8", - timeout: 3000, - windowsHide: true, - }) - if (result.status !== 0) continue - const output = (result.stdout || "").trim().toLowerCase() - if (output === "true" || output === "1") return true - if (output === "false" || output === "0") return false - } catch { - continue - } - } - } - - return false -} - -function isMusl() { - if (platform !== "linux") return false - - try { - if (fs.existsSync("/etc/alpine-release")) return true - } catch { - // Ignore filesystem probes that are blocked by the host. - } - - try { - const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) - return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl") - } catch { - return false - } -} - -function packageNames() { - const baseline = arch === "x64" && !supportsAvx2() - - if (platform === "linux") { - if (isMusl()) { - if (arch === "x64") - return baseline - ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] - : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] - return [`${base}-musl`, base] - } - - if (arch === "x64") - return baseline - ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] - : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] - return [base, `${base}-musl`] - } - - if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`] - return [base] -} - -function resolveBinary(name) { - const packageJsonPath = require.resolve(`${name}/package.json`) - const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary) - if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`) - return binaryPath -} - -function installPackage(name) { - const version = packageJson.optionalDependencies?.[name] - if (!version) return - - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-")) - try { - const result = childProcess.spawnSync( - "npm", - ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`], - { stdio: "inherit", windowsHide: true }, - ) - if (result.status !== 0) return - const packageDir = path.join(temp, "node_modules", name) - copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary) - return true - } finally { - fs.rmSync(temp, { recursive: true, force: true }) - } -} - -function copyBinary(source, target) { - if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`) - fs.mkdirSync(path.dirname(target), { recursive: true }) - if (fs.existsSync(target)) fs.unlinkSync(target) - try { - fs.linkSync(source, target) - } catch { - fs.copyFileSync(source, target) - } - fs.chmodSync(target, 0o755) -} - -function verifyBinary() { - const result = childProcess.spawnSync(targetBinary, ["--version"], { - encoding: "utf8", - stdio: "ignore", - windowsHide: true, - }) - return result.status === 0 -} - -function main() { - for (const name of packageNames()) { - try { - copyBinary(resolveBinary(name), targetBinary) - if (verifyBinary()) return - } catch { - if (installPackage(name) && verifyBinary()) return - } - } - - throw new Error( - `It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames() - .map((name) => JSON.stringify(name)) - .join(" or ")}.`, - ) -} - -try { - main() -} catch (error) { - console.error(error.message) - process.exit(1) -} +console.error( + [ + "DeepCode package-manager installation is disabled in Alpha.", + "Use the verified GitHub Release/curl installer so the archive checksum and install manifest are validated.", + "No npm, OpenCode, Homebrew, Chocolatey, or Scoop endpoint was contacted.", + ].join(" "), +) +process.exit(1) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index ac1cafff..388e9604 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -1,213 +1,9 @@ #!/usr/bin/env bun -import { $ } from "bun" -import pkg from "../package.json" -import { Script } from "@opencode-ai/script" -import { fileURLToPath } from "url" -const dir = fileURLToPath(new URL("..", import.meta.url)) -process.chdir(dir) - -async function published(name: string, version: string) { - return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 -} - -async function publish(dir: string, name: string, version: string) { - // GitHub artifact downloads can drop the executable bit, and Docker uses the - // unpacked dist binaries directly rather than the published tarball. - if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) - if (await published(name, version)) { - console.log(`already published ${name}@${version}`) - return - } - await $`bun pm pack`.cwd(dir) - 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 pkg = await Bun.file(`./dist/${filepath}`).json() - binaries[pkg.name] = pkg.version -} -console.log("binaries", binaries) -const version = Object.values(binaries)[0] - -await $`mkdir -p ./dist/${pkg.name}` -await $`mkdir -p ./dist/${pkg.name}/bin` -await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` -await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) -await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write( +throw new Error( [ - `echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`, - 'echo "" >&2', - 'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2', - 'echo "package manager like pnpm that does not run postinstall scripts by default." >&2', - 'echo "" >&2', - 'echo "To fix this, run the postinstall script manually:" >&2', - `echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`, - 'echo "" >&2', - `echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`, - "exit 1", - "", - ].join("\n"), + "Legacy package-manager, container, AUR, and Homebrew publishing is disabled for DeepCode Alpha.", + "S1-03 permits only DeepCode-owned GitHub Release artifacts produced by script/build.ts.", + "Do not restore anomalyco/opencode, npm, brew, choco, scoop, AUR, or GHCR fallbacks.", + ].join(" "), ) - -await Bun.file(`./dist/${pkg.name}/package.json`).write( - JSON.stringify( - { - name: pkg.name + "-ai", - bin: { - [pkg.name]: `./bin/${pkg.name}.exe`, - }, - scripts: { - postinstall: "node ./postinstall.mjs", - }, - version: version, - license: pkg.license, - os: ["darwin", "linux", "win32"], - cpu: ["arm64", "x64"], - optionalDependencies: binaries, - }, - null, - 2, - ), -) - -const tasks = Object.entries(binaries).map(async ([name]) => { - await publish(`./dist/${name}`, name, binaries[name]) -}) -await Promise.all(tasks) -await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version) - -const image = "ghcr.io/anomalyco/opencode" -const platforms = "linux/amd64,linux/arm64" -const tags = [`${image}:${version}`, `${image}:${Script.channel}`] -const tagFlags = tags.flatMap((t) => ["-t", t]) - -// registries -if (!Script.preview) { - await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` - // Calculate SHA values - const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) - const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) - - const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2) - - // arch - const binaryPkgbuild = [ - "# Maintainer: dax", - "# Maintainer: adam", - "", - "pkgname='opencode-bin'", - `pkgver=${pkgver}`, - `_subver=${_subver}`, - "options=('!debug' '!strip')", - "pkgrel=1", - "pkgdesc='The AI coding agent built for the terminal.'", - "url='https://github.com/anomalyco/opencode'", - "arch=('aarch64' 'x86_64')", - "license=('MIT')", - "provides=('opencode')", - "conflicts=('opencode')", - "depends=('ripgrep')", - "", - `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`, - `sha256sums_aarch64=('${arm64Sha}')`, - - `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`, - `sha256sums_x86_64=('${x64Sha}')`, - "", - "package() {", - ' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"', - "}", - "", - ].join("\n") - - for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { - for (let i = 0; i < 30; i++) { - try { - await $`rm -rf ./dist/aur-${pkg}` - await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}` - await $`cd ./dist/aur-${pkg} && git checkout master` - await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild) - await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO` - await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO` - if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break - await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"` - await $`cd ./dist/aur-${pkg} && git push` - break - } catch { - continue - } - } - } - - // Homebrew formula - const homebrewFormula = [ - "# typed: false", - "# frozen_string_literal: true", - "", - "# This file was generated by GoReleaser. DO NOT EDIT.", - "class Opencode < Formula", - ` desc "The AI coding agent built for the terminal."`, - ` homepage "https://github.com/anomalyco/opencode"`, - ` version "${Script.version.split("-")[0]}"`, - "", - ` depends_on "ripgrep"`, - "", - " on_macos do", - " if Hardware::CPU.intel?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`, - ` sha256 "${macX64Sha}"`, - "", - " def install", - ' bin.install "opencode"', - " end", - " end", - " if Hardware::CPU.arm?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`, - ` sha256 "${macArm64Sha}"`, - "", - " def install", - ' bin.install "opencode"', - " end", - " end", - " end", - "", - " on_linux do", - " if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`, - ` sha256 "${x64Sha}"`, - " def install", - ' bin.install "opencode"', - " end", - " end", - " if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`, - ` sha256 "${arm64Sha}"`, - " def install", - ' bin.install "opencode"', - " end", - " end", - " end", - "end", - "", - "", - ].join("\n") - - const token = process.env.GITHUB_TOKEN - if (!token) { - console.error("GITHUB_TOKEN is required to update homebrew tap") - process.exit(1) - } - const tap = `https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git` - await $`rm -rf ./dist/homebrew-tap` - await $`git clone ${tap} ./dist/homebrew-tap` - await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula) - await $`cd ./dist/homebrew-tap && git add opencode.rb` - if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) { - await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` - await $`cd ./dist/homebrew-tap && git push` - } -} diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index c004d471..1adf1917 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -1,6 +1,6 @@ import type { Argv } from "yargs" +import { confirm, intro, isCancel, log, outro, spinner } from "@clack/prompts" import { UI } from "../ui" -import * as prompts from "@clack/prompts" import { Installation } from "../../installation" import { Global } from "@opencode-ai/core/global" import { Product } from "@opencode-ai/core/product" @@ -10,14 +10,14 @@ import os from "os" import { Filesystem } from "@/util/filesystem" interface UninstallArgs { - keepConfig: boolean - keepData: boolean + purge: boolean dryRun: boolean force: boolean } interface RemovalTargets { - directories: Array<{ path: string; label: string; keep: boolean }> + files: string[] + purgeDirectories: Array<{ path: string; label: string }> shellConfig: string | null binary: string | null } @@ -54,17 +54,14 @@ export function cleanShellConfigContent(content: string) { for (const line of content.split("\n")) { const trimmed = line.trim() - if (trimmed === shellMarker) { afterMarker = true continue } - if (afterMarker) { afterMarker = false if (isDeepCodeShellPathLine(line)) continue } - if (isDeepCodeShellPathLine(line)) continue filtered.push(line) } @@ -75,24 +72,17 @@ export function cleanShellConfigContent(content: string) { export const UninstallCommand = { command: "uninstall", - describe: "uninstall DeepCode and remove DeepCode-owned files", + describe: "uninstall DeepCode while preserving user data by default", builder: (yargs: Argv) => yargs - .option("keep-config", { - alias: "c", - type: "boolean", - describe: "keep configuration files", - default: false, - }) - .option("keep-data", { - alias: "d", + .option("purge", { type: "boolean", - describe: "keep session data and snapshots", + describe: "also delete DeepCode configuration, sessions, cache, and state", default: false, }) .option("dry-run", { type: "boolean", - describe: "show what would be removed without removing", + describe: "show what would be removed without removing it", default: false, }) .option("force", { @@ -106,197 +96,193 @@ export const UninstallCommand = { UI.empty() UI.println(UI.logo(" ")) UI.empty() - prompts.intro(`Uninstall ${Product.name}`) + intro(`Uninstall ${Product.name}`) const method = await Installation.method() - prompts.log.info(`Installation method: ${method}`) - + log.info(`Installation method: ${method}`) const targets = await collectRemovalTargets(args, method) - await showRemovalSummary(targets) + await showRemovalSummary(targets, args.purge) if (!args.force && !args.dryRun) { - const confirm = await prompts.confirm({ - message: `Are you sure you want to uninstall ${Product.name}?`, + const approved = await confirm({ + message: `Remove the verified ${Product.name} installation files?`, initialValue: false, }) - if (!confirm || prompts.isCancel(confirm)) { - prompts.outro("Cancelled") + if (!approved || isCancel(approved)) { + outro("Cancelled") return } + + if (args.purge) { + const purgeApproved = await confirm({ + message: "Permanently delete all DeepCode user data listed above?", + initialValue: false, + }) + if (!purgeApproved || isCancel(purgeApproved)) { + outro("Cancelled before purge") + return + } + } } if (args.dryRun) { - prompts.log.warn("Dry run - no changes made") - prompts.outro("Done") + log.warn("Dry run - no changes made") + outro("Done") return } await executeUninstall(targets) - prompts.outro("Done") + outro("Done") }, } async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise { - const directories: RemovalTargets["directories"] = [ - { path: Global.Path.data, label: "Data", keep: args.keepData }, - { path: Global.Path.cache, label: "Cache", keep: false }, - { path: Global.Path.config, label: "Config", keep: args.keepConfig }, - { path: Global.Path.state, label: "State", keep: false }, - ] - const curlInstall = method === "curl" && isDeepCodeBinaryPath(process.execPath) - const shellConfig = curlInstall ? await getShellConfigFile() : null - const binary = curlInstall ? process.execPath : null - - return { directories, shellConfig, binary } + const manifest = curlInstall ? await Installation.readInstallManifest(process.execPath) : null + const files = manifest?.files.filter((file) => Installation.isOwnedInstallPath(file, process.execPath)) ?? [] + const purgeDirectories = args.purge + ? [ + { path: Global.Path.data, label: "Data" }, + { path: Global.Path.cache, label: "Cache" }, + { path: Global.Path.config, label: "Config" }, + { path: Global.Path.state, label: "State" }, + ] + : [] + + return { + files, + purgeDirectories, + shellConfig: curlInstall ? await getShellConfigFile() : null, + binary: curlInstall ? process.execPath : null, + } } -async function showRemovalSummary(targets: RemovalTargets) { - prompts.log.message("The following DeepCode-owned targets will be removed:") - - for (const dir of targets.directories) { - const exists = await fs - .access(dir.path) - .then(() => true) - .catch(() => false) - if (!exists) continue - - const size = await getDirectorySize(dir.path) - const sizeStr = formatSize(size) - const status = dir.keep ? UI.Style.TEXT_DIM + "(keeping)" : "" - const prefix = dir.keep ? "○" : "✓" +async function showRemovalSummary(targets: RemovalTargets, purge: boolean) { + log.message("The following manifest-owned DeepCode installation files will be removed:") + if (targets.files.length === 0) log.warn(" No valid DeepCode install manifest was found; no binary file will be deleted automatically.") + for (const file of targets.files) log.info(` ✓ ${shortenPath(file)}`) + if (targets.shellConfig) log.info(` ✓ DeepCode PATH entry in ${shortenPath(targets.shellConfig)}`) - prompts.log.info(` ${prefix} ${dir.label}: ${shortenPath(dir.path)} ${UI.Style.TEXT_DIM}(${sizeStr})${status}`) + if (!purge) { + log.info("Configuration, sessions, cache, and state will be preserved. Use --purge to delete them.") + return } - if (targets.binary) prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`) - if (targets.shellConfig) prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`) + log.warn("Purge requested. These DeepCode-only user directories will also be deleted:") + for (const directory of targets.purgeDirectories) { + const exists = await pathExists(directory.path) + if (!exists) continue + log.info(` ! ${directory.label}: ${shortenPath(directory.path)} (${formatSize(await getDirectorySize(directory.path))})`) + } } async function executeUninstall(targets: RemovalTargets) { - const spinner = prompts.spinner() + const progress = spinner() const errors: string[] = [] - for (const dir of targets.directories) { - if (dir.keep) { - prompts.log.step(`Skipping ${dir.label} (--keep-${dir.label.toLowerCase()})`) - continue - } - - const exists = await fs - .access(dir.path) - .then(() => true) - .catch(() => false) - if (!exists) continue - - spinner.start(`Removing ${dir.label}...`) - const err = await fs.rm(dir.path, { recursive: true, force: true }).catch((error) => error) - if (err) { - spinner.stop(`Failed to remove ${dir.label}`, 1) - errors.push(`${dir.label}: ${err.message}`) + for (const directory of targets.purgeDirectories) { + if (!(await pathExists(directory.path))) continue + progress.start(`Removing DeepCode ${directory.label}...`) + const error = await fs.rm(directory.path, { recursive: true, force: true }).catch((cause) => cause) + if (error instanceof Error) { + progress.stop(`Failed to remove ${directory.label}`, 1) + errors.push(`${directory.label}: ${error.message}`) continue } - spinner.stop(`Removed ${dir.label}`) + progress.stop(`Removed ${directory.label}`) } if (targets.shellConfig) { - spinner.start("Cleaning DeepCode shell configuration...") - const err = await cleanShellConfig(targets.shellConfig).catch((error) => error) - if (err) { - spinner.stop("Failed to clean shell config", 1) - errors.push(`Shell config: ${err.message}`) + progress.start("Cleaning DeepCode shell configuration...") + const error = await cleanShellConfig(targets.shellConfig).catch((cause) => cause) + if (error instanceof Error) { + progress.stop("Failed to clean shell config", 1) + errors.push(`Shell config: ${error.message}`) } else { - spinner.stop("Cleaned shell config") + progress.stop("Cleaned shell config") } } - if (targets.binary) { - UI.empty() - prompts.log.message("To finish removing the DeepCode binary, remove:") - prompts.log.info(` ${targets.binary}`) - - const binDir = path.dirname(targets.binary) - if (path.basename(binDir) === "bin" && path.basename(path.dirname(binDir)) === Product.config.directory) { - prompts.log.info(` Empty directory after removal: ${binDir}`) + const orderedFiles = targets.files.toSorted((left, right) => { + if (left === targets.binary) return 1 + if (right === targets.binary) return -1 + return right.length - left.length + }) + for (const file of orderedFiles) { + progress.start(`Removing ${shortenPath(file)}...`) + const error = await fs.rm(file, { force: true }).catch((cause) => cause) + if (error instanceof Error) { + progress.stop(`Failed to remove ${shortenPath(file)}`, 1) + errors.push(`${shortenPath(file)}: ${error.message}`) + continue } + progress.stop(`Removed ${shortenPath(file)}`) + } + + if (targets.binary) { + await removeEmptyDirectory(path.dirname(targets.binary)) + await removeEmptyDirectory(path.dirname(path.dirname(targets.binary))) } if (errors.length > 0) { UI.empty() - prompts.log.warn("Some operations failed:") - for (const err of errors) prompts.log.error(` ${err}`) + log.warn("Some operations failed:") + for (const error of errors) log.error(` ${error}`) } UI.empty() - prompts.log.success(`Thank you for using ${Product.name}!`) + log.success(`Thank you for using ${Product.name}!`) } async function getShellConfigFile(): Promise { const shell = path.basename(process.env.SHELL || "bash") const home = os.homedir() const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, ".config") - const configFiles: Record = { fish: [path.join(xdgConfig, "fish", "config.fish")], - zsh: [ - path.join(home, ".zshrc"), - path.join(home, ".zshenv"), - path.join(xdgConfig, "zsh", ".zshrc"), - path.join(xdgConfig, "zsh", ".zshenv"), - ], - bash: [ - path.join(home, ".bashrc"), - path.join(home, ".bash_profile"), - path.join(home, ".profile"), - path.join(xdgConfig, "bash", ".bashrc"), - path.join(xdgConfig, "bash", ".bash_profile"), - ], + zsh: [path.join(home, ".zshrc"), path.join(home, ".zshenv"), path.join(xdgConfig, "zsh", ".zshrc")], + bash: [path.join(home, ".bashrc"), path.join(home, ".bash_profile"), path.join(home, ".profile")], ash: [path.join(home, ".ashrc"), path.join(home, ".profile")], sh: [path.join(home, ".profile")], } - const candidates = configFiles[shell] || configFiles.bash - - for (const file of candidates) { - const exists = await fs - .access(file) - .then(() => true) - .catch(() => false) - if (!exists) continue - + for (const file of configFiles[shell] || configFiles.bash) { + if (!(await pathExists(file))) continue const content = await Filesystem.readText(file).catch(() => "") if (hasDeepCodeShellEntry(content)) return file } - return null } async function cleanShellConfig(file: string) { - const content = await Filesystem.readText(file) - await Filesystem.write(file, cleanShellConfigContent(content)) + await Filesystem.write(file, cleanShellConfigContent(await Filesystem.readText(file))) } -async function getDirectorySize(dir: string): Promise { - let total = 0 - - const walk = async (current: string) => { - const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []) +async function pathExists(value: string) { + return fs.access(value).then( + () => true, + () => false, + ) +} - for (const entry of entries) { - const full = path.join(current, entry.name) - if (entry.isDirectory()) { - await walk(full) - continue - } - if (entry.isFile()) { - const stat = await fs.stat(full).catch(() => null) - if (stat) total += stat.size - } - } - } +async function removeEmptyDirectory(value: string) { + await fs.rmdir(value).catch(() => undefined) +} - await walk(dir) - return total +async function getDirectorySize(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []) + const sizes = await Promise.all( + entries.map(async (entry) => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) return getDirectorySize(full) + if (!entry.isFile()) return 0 + return fs.stat(full).then( + (stat) => stat.size, + () => 0, + ) + }), + ) + return sizes.reduce((total, size) => total + size, 0) } function formatSize(bytes: number): string { @@ -308,6 +294,5 @@ function formatSize(bytes: number): string { function shortenPath(value: string): string { const home = os.homedir() - if (value.startsWith(home)) return value.replace(home, "~") - return value + return value.startsWith(home) ? value.replace(home, "~") : value } diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index dc0963bf..c4130f5e 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -38,24 +38,18 @@ export const UpgradeCommand = { } const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest(method) - if (!args.target && target === InstallationVersion) { - prompts.log.warn("Automatic upgrades are disabled until the verified DeepCode release channel is available.") - prompts.outro("Done") - return - } - if (InstallationVersion === target) { - prompts.log.warn(`${Product.name} upgrade skipped: ${target} is already installed`) + prompts.log.warn(`${Product.name} ${InstallationVersion} is already the latest verified release`) prompts.outro("Done") return } prompts.log.info(`From ${InstallationVersion} → ${target}`) const spinner = prompts.spinner() - spinner.start("Upgrading...") + spinner.start("Downloading and verifying DeepCode release...") const err = await Installation.upgrade(method, target).catch((error) => error) if (err) { - spinner.stop("Upgrade failed", 1) + spinner.stop("Upgrade failed; the previous binary was preserved or restored", 1) if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.stderr) else if (err instanceof Error) prompts.log.error(err.message) prompts.outro("Done") diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 0589e141..b0b9da4f 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -8,6 +8,7 @@ import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { Product } from "@opencode-ai/core/product" import { InstallationEvent } from "@opencode-ai/schema/installation-event" +import { latestVersion, upgradeCurl } from "./release" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" @@ -89,14 +90,26 @@ const service: Interface = { method: Effect.fn("Installation.method")(function* () { return detectMethod(process.execPath) }), - latest: Effect.fn("Installation.latest")(function* (_method?: Method) { - // S1-02 must never fall back to OpenCode release, package-manager, or install-script endpoints. - // S1-03 will replace this safe placeholder with the verified DeepCode GitHub Release channel. - return InstallationVersion + latest: Effect.fn("Installation.latest")(function* (method: Method = "curl") { + if (method !== "curl") return InstallationVersion + return yield* Effect.tryPromise({ + try: () => latestVersion(), + catch: (error) => error, + }).pipe(Effect.orElseSucceed(() => InstallationVersion)) }), upgrade: Effect.fn("Installation.upgrade")(function* (method: Method, target: string) { - return yield* new UpgradeFailedError({ - stderr: `DeepCode upgrade is disabled until the verified release channel is available (method: ${method}, target: ${target}).`, + if (method !== "curl") { + return yield* new UpgradeFailedError({ + stderr: `DeepCode ${method} upgrades are disabled in Alpha. Install through the verified GitHub Release/curl channel.`, + }) + } + + return yield* Effect.tryPromise({ + try: () => upgradeCurl(target), + catch: (error) => + new UpgradeFailedError({ + stderr: error instanceof Error ? error.message : String(error), + }), }) }), } @@ -111,4 +124,27 @@ export const latest = (...args: Parameters) => runPromise(( export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) -export * as Installation from "." +export { + ReleaseRepository, + ReleaseBaseUrl, + ReleaseManifestName, + ReleaseChecksumsName, + InstallManifestName, + normalizeVersion, + platformName, + architectureName, + artifactName, + releaseUrl, + sha256, + parseReleaseManifest, + parseInstallManifest, + latestVersion, + downloadVerifiedRelease, + upgradeCurl, + replaceBinaryAtomically, + readInstallManifest, + installManifestPath, + isOwnedInstallPath, +} from "./release" + +export * as Installation from "." \ No newline at end of file diff --git a/packages/opencode/src/installation/release.ts b/packages/opencode/src/installation/release.ts new file mode 100644 index 00000000..110f27f3 --- /dev/null +++ b/packages/opencode/src/installation/release.ts @@ -0,0 +1,284 @@ +import { Product } from "@opencode-ai/core/product" +import { createHash } from "crypto" +import { spawnSync } from "child_process" +import fs from "fs/promises" +import path from "path" +import semver from "semver" + +export const ReleaseRepository = "yuanchenglu/deepcode" +export const ReleaseBaseUrl = `https://github.com/${ReleaseRepository}/releases/download` +export const LatestReleaseUrl = `https://api.github.com/repos/${ReleaseRepository}/releases/latest` +export const ReleaseManifestName = "deepcode-manifest.json" +export const ReleaseChecksumsName = "deepcode-checksums.txt" +export const InstallManifestName = "install-manifest.json" + +export type SupportedPlatform = "darwin" | "linux" | "windows" +export type SupportedArchitecture = "arm64" | "x64" + +export interface ReleaseAsset { + readonly name: string + readonly sha256: string +} + +export interface ReleaseManifest { + readonly schema: 1 + readonly product: "deepcode" + readonly version: string + readonly assets: readonly ReleaseAsset[] +} + +export interface InstallManifest { + readonly schema: 1 + readonly product: "deepcode" + readonly version: string + readonly files: readonly string[] +} + +export interface DownloadOptions { + readonly releaseBaseUrl?: string + readonly platform?: NodeJS.Platform | SupportedPlatform + readonly architecture?: string + readonly fetcher?: typeof fetch +} + +export interface UpgradeOptions extends DownloadOptions { + readonly execPath?: string + readonly prepareCandidate?: (asset: DownloadedRelease, directory: string) => Promise + readonly verifyBinary?: (binary: string, version: string) => Promise + readonly afterReplace?: () => Promise +} + +export interface DownloadedRelease { + readonly manifest: ReleaseManifest + readonly asset: ReleaseAsset + readonly bytes: Uint8Array +} + +export function normalizeVersion(value: string) { + const normalized = value.replace(/^v/, "") + if (!semver.valid(normalized)) throw new Error(`Invalid DeepCode release version: ${value}`) + return normalized +} + +export function platformName(value: NodeJS.Platform | SupportedPlatform = process.platform): SupportedPlatform { + if (value === "darwin" || value === "linux" || value === "windows") return value + if (value === "win32") return "windows" + throw new Error(`DeepCode release does not support platform: ${value}`) +} + +export function architectureName(value: string = process.arch): SupportedArchitecture { + if (value === "arm64" || value === "x64") return value + throw new Error(`DeepCode release does not support architecture: ${value}`) +} + +export function artifactName( + platform: NodeJS.Platform | SupportedPlatform = process.platform, + architecture: string = process.arch, +) { + const os = platformName(platform) + const arch = architectureName(architecture) + const extension = os === "linux" ? "tar.gz" : "zip" + return `${Product.slug}-${os}-${arch}.${extension}` +} + +export function releaseUrl(version: string, file: string, baseUrl = ReleaseBaseUrl) { + return `${baseUrl.replace(/\/$/, "")}/v${normalizeVersion(version)}/${file}` +} + +export function sha256(bytes: Uint8Array) { + return createHash("sha256").update(bytes).digest("hex") +} + +export function parseReleaseManifest(input: string): ReleaseManifest { + const value: unknown = JSON.parse(input) + const root = requireRecord(value, "release manifest") + if (root.schema !== 1) throw new Error("Unsupported DeepCode release manifest schema") + if (root.product !== Product.slug) throw new Error("Release manifest is not owned by DeepCode") + if (typeof root.version !== "string") throw new Error("Release manifest version is missing") + const version = normalizeVersion(root.version) + if (!Array.isArray(root.assets)) throw new Error("Release manifest assets are missing") + + const names = new Set() + const assets = root.assets.map((item) => { + const asset = requireRecord(item, "release asset") + if (typeof asset.name !== "string" || !asset.name.startsWith(`${Product.slug}-`)) { + throw new Error("Release manifest contains a non-DeepCode asset") + } + if (typeof asset.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(asset.sha256)) { + throw new Error(`Release manifest contains an invalid checksum for ${asset.name}`) + } + if (names.has(asset.name)) throw new Error(`Release manifest contains duplicate asset ${asset.name}`) + names.add(asset.name) + return { name: asset.name, sha256: asset.sha256.toLowerCase() } + }) + + return { schema: 1, product: "deepcode", version, assets } +} + +export function parseInstallManifest(input: string, execPath: string): InstallManifest { + const value: unknown = JSON.parse(input) + const root = requireRecord(value, "install manifest") + if (root.schema !== 1) throw new Error("Unsupported DeepCode install manifest schema") + if (root.product !== Product.slug) throw new Error("Install manifest is not owned by DeepCode") + if (typeof root.version !== "string") throw new Error("Install manifest version is missing") + const version = normalizeVersion(root.version) + if (!Array.isArray(root.files)) throw new Error("Install manifest files are missing") + + const files = root.files.map((item) => { + if (typeof item !== "string" || !isOwnedInstallPath(item, execPath)) { + throw new Error("Install manifest contains a path outside the verified DeepCode install file set") + } + return path.normalize(item) + }) + if (new Set(files).size !== files.length) throw new Error("Install manifest contains duplicate paths") + return { schema: 1, product: "deepcode", version, files } +} + +export async function latestVersion(fetcher: typeof fetch = fetch, url = LatestReleaseUrl) { + const response = await fetcher(url, { headers: { accept: "application/vnd.github+json" } }) + if (!response.ok) throw new Error(`DeepCode latest release lookup failed: HTTP ${response.status}`) + const value: unknown = await response.json() + const root = requireRecord(value, "latest release") + if (typeof root.tag_name !== "string") throw new Error("DeepCode latest release tag is missing") + return normalizeVersion(root.tag_name) +} + +export async function downloadVerifiedRelease(version: string, options: DownloadOptions = {}): Promise { + const target = normalizeVersion(version) + const fetcher = options.fetcher ?? fetch + const name = artifactName(options.platform, options.architecture) + const manifestResponse = await fetcher(releaseUrl(target, ReleaseManifestName, options.releaseBaseUrl)) + if (!manifestResponse.ok) throw new Error(`DeepCode release manifest download failed: HTTP ${manifestResponse.status}`) + const manifest = parseReleaseManifest(await manifestResponse.text()) + if (manifest.version !== target) throw new Error(`DeepCode release manifest version mismatch: expected ${target}`) + const asset = manifest.assets.find((item) => item.name === name) + if (!asset) throw new Error(`DeepCode release manifest does not contain ${name}`) + + const response = await fetcher(releaseUrl(target, name, options.releaseBaseUrl)) + if (!response.ok) throw new Error(`DeepCode release asset download failed: HTTP ${response.status}`) + const bytes = new Uint8Array(await response.arrayBuffer()) + const actual = sha256(bytes) + if (actual !== asset.sha256) throw new Error(`DeepCode release checksum mismatch for ${name}`) + return { manifest, asset, bytes } +} + +export async function upgradeCurl(version: string, options: UpgradeOptions = {}) { + const target = normalizeVersion(version) + const execPath = path.resolve(options.execPath ?? process.execPath) + if (!isOwnedInstallPath(execPath, execPath)) throw new Error(`${execPath} is not a verified DeepCode curl installation`) + + const downloaded = await downloadVerifiedRelease(target, options) + const directory = await fs.mkdtemp(path.join(path.dirname(execPath), ".deepcode-upgrade-")) + const prepareCandidate = options.prepareCandidate ?? prepareReleaseCandidate + const verifyBinary = options.verifyBinary ?? verifyReleaseBinary + + try { + const candidate = await prepareCandidate(downloaded, directory) + await replaceBinaryAtomically(execPath, candidate, target, { + verifyBinary, + afterReplace: async () => { + await writeInstallManifest(execPath, target) + await options.afterReplace?.() + }, + }) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } +} + +export async function replaceBinaryAtomically( + execPath: string, + candidate: string, + version: string, + options: { + readonly verifyBinary: (binary: string, version: string) => Promise + readonly afterReplace?: () => Promise + }, +) { + const backup = `${execPath}.previous` + await options.verifyBinary(candidate, version) + await fs.rm(backup, { force: true }) + await fs.rename(execPath, backup) + + try { + await fs.rename(candidate, execPath) + await options.verifyBinary(execPath, version) + await options.afterReplace?.() + } catch (error) { + await fs.rm(execPath, { force: true }) + const restoreError = await fs.rename(backup, execPath).catch((cause) => cause) + if (restoreError instanceof Error) { + throw new Error(`DeepCode upgrade failed and rollback failed: ${restoreError.message}`, { cause: error }) + } + throw error + } +} + +export async function readInstallManifest(execPath: string) { + const file = installManifestPath(execPath) + const content = await fs.readFile(file, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null + throw error + }) + return content === null ? null : parseInstallManifest(content, execPath) +} + +export function installManifestPath(execPath: string) { + return path.join(path.dirname(path.dirname(path.resolve(execPath))), InstallManifestName) +} + +export function ownedInstallFiles(execPath: string) { + const executable = path.resolve(execPath) + return [executable, path.resolve(`${executable}.previous`), path.resolve(installManifestPath(executable))] +} + +export function isOwnedInstallPath(value: string, execPath: string) { + const resolved = path.resolve(value) + const compare = (left: string, right: string) => + process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right + return ownedInstallFiles(execPath).some((candidate) => compare(resolved, candidate)) +} + +async function prepareReleaseCandidate(downloaded: DownloadedRelease, directory: string) { + const platform = platformName() + if (platform === "windows") throw new Error("DeepCode curl upgrades on Windows are not enabled in Alpha") + + const archive = path.join(directory, downloaded.asset.name) + const extract = path.join(directory, "extract") + await fs.writeFile(archive, downloaded.bytes) + await fs.mkdir(extract) + const command = platform === "linux" ? ["tar", "-xzf", archive, "-C", extract] : ["unzip", "-q", archive, "-d", extract] + const result = spawnSync(command[0], command.slice(1), { encoding: "utf8", timeout: 30_000 }) + if (result.status !== 0) throw new Error(`DeepCode release extraction failed: ${(result.stderr || "").trim()}`) + + const candidate = path.join(extract, Product.cli) + const stat = await fs.lstat(candidate).catch(() => null) + if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error("DeepCode release archive does not contain one native deepcode binary") + await fs.chmod(candidate, 0o755) + return candidate +} + +async function verifyReleaseBinary(binary: string, version: string) { + const result = spawnSync(binary, ["--version"], { encoding: "utf8", timeout: 15_000 }) + const output = `${result.stdout || ""}${result.stderr || ""}`.trim() + if (result.status !== 0) throw new Error(`DeepCode release smoke test failed: ${output}`) + if (!output.includes(version)) throw new Error(`DeepCode release version mismatch: expected ${version}, received ${output}`) +} + +async function writeInstallManifest(execPath: string, version: string) { + const file = installManifestPath(execPath) + const manifest: InstallManifest = { + schema: 1, + product: "deepcode", + version, + files: ownedInstallFiles(execPath), + } + const temporary = `${file}.tmp` + await fs.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`) + await fs.rename(temporary, file) +} + +function requireRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`Invalid ${label}`) + return value as Record +} diff --git a/packages/opencode/test/installation/build-contract.test.ts b/packages/opencode/test/installation/build-contract.test.ts new file mode 100644 index 00000000..971db53e --- /dev/null +++ b/packages/opencode/test/installation/build-contract.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +const root = path.resolve(import.meta.dir, "../..") + +async function source(file: string) { + return Bun.file(path.join(root, file)).text() +} + +describe("DeepCode CLI artifact contract", () => { + test("package and launcher expose only the deepcode command", async () => { + const pkg = await Bun.file(path.join(root, "package.json")).json() + expect(pkg.bin).toEqual({ deepcode: "./bin/deepcode" }) + expect(await Bun.file(path.join(root, "bin/deepcode")).exists()).toBe(true) + expect(await Bun.file(path.join(root, "bin/opencode")).exists()).toBe(false) + + const launcher = await source("bin/deepcode") + expect(launcher).toContain("DEEPCODE_BIN_PATH") + expect(launcher).toContain("deepcode-") + expect(launcher).not.toContain("OPENCODE_BIN_PATH") + }) + + test("build emits a deepcode binary, canonical archives, checksums, and a manifest", async () => { + const build = await source("script/build.ts") + expect(build).toContain("deepcode-manifest.json") + expect(build).toContain("deepcode-checksums.txt") + expect(build).toContain("--user-agent=${pkg.name}") + expect(build).not.toContain("bin/opencode") + expect(build).not.toContain("--user-agent=opencode") + }) + + test("Alpha installers and publishers cannot request upstream or package-manager release channels", async () => { + const install = await source("script/install.sh") + const postinstall = await source("script/postinstall.mjs") + const publish = await source("script/publish.ts") + const lifecycle = `${install}\n${postinstall}\n${publish}` + + expect(install).toContain('repo="yuanchenglu/deepcode"') + expect(install).toContain("deepcode-checksums.txt") + expect(install).toContain("checksum mismatch") + expect(lifecycle).not.toContain("https://github.com/anomalyco") + expect(lifecycle).not.toContain("npm install") + expect(lifecycle).not.toContain("npm publish") + expect(lifecycle).not.toContain("brew install") + }) +}) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 0b191e26..42b66ed4 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -43,13 +43,10 @@ describe("installation", () => { }) describe("release lookup", () => { - testEffect(layer).effect("returns the installed version without contacting an upstream channel", () => + testEffect(layer).effect("keeps unsupported channels fail-closed", () => Effect.gen(function* () { - const methods: Installation.Method[] = ["curl", "npm", "yarn", "pnpm", "bun", "brew", "scoop", "choco", "unknown"] - for (const method of methods) { - expect(yield* Installation.use.latest(method)).toBe(InstallationVersion) - } - + const methods: Installation.Method[] = ["npm", "yarn", "pnpm", "bun", "brew", "scoop", "choco", "unknown"] + for (const method of methods) expect(yield* Installation.use.latest(method)).toBe(InstallationVersion) expect(yield* Installation.use.info()).toEqual({ version: InstallationVersion, latest: InstallationVersion, @@ -59,14 +56,14 @@ describe("installation", () => { }) describe("upgrade", () => { - for (const method of ["curl", "npm", "yarn", "pnpm", "bun", "brew", "scoop", "choco", "unknown"] as const) { - testEffect(layer).effect(`rejects ${method} without executing an upstream installer`, () => + for (const method of ["npm", "yarn", "pnpm", "bun", "brew", "scoop", "choco", "unknown"] as const) { + testEffect(layer).effect(`rejects ${method} without executing a package-manager or upstream installer`, () => Effect.gen(function* () { const error = yield* Effect.flip(Installation.use.upgrade(method, "9.9.9")) expect(error).toBeInstanceOf(Installation.UpgradeFailedError) expect(error.message).toBe(error.stderr) - expect(error.stderr).toContain("DeepCode upgrade is disabled") - expect(error.stderr).toContain(`method: ${method}`) + expect(error.stderr).toContain(`${method} upgrades are disabled`) + expect(error.stderr).toContain("verified GitHub Release/curl channel") expect(error.stderr).not.toContain("opencode.ai") expect(error.stderr).not.toContain("anomalyco") }), diff --git a/packages/opencode/test/installation/release.test.ts b/packages/opencode/test/installation/release.test.ts new file mode 100644 index 00000000..c2be0488 --- /dev/null +++ b/packages/opencode/test/installation/release.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { + artifactName, + downloadVerifiedRelease, + parseInstallManifest, + parseReleaseManifest, + replaceBinaryAtomically, + sha256, +} from "../../src/installation/release" + +const version = "1.2.3" +const asset = "deepcode-linux-x64.tar.gz" +const bytes = new TextEncoder().encode("verified DeepCode archive fixture") + +describe("DeepCode release lifecycle", () => { + test("uses one canonical artifact name per supported platform and architecture", () => { + expect(artifactName("linux", "x64")).toBe("deepcode-linux-x64.tar.gz") + expect(artifactName("darwin", "arm64")).toBe("deepcode-darwin-arm64.zip") + expect(artifactName("win32", "x64")).toBe("deepcode-windows-x64.zip") + expect(() => artifactName("freebsd", "x64")).toThrow("does not support platform") + expect(() => artifactName("linux", "ia32")).toThrow("does not support architecture") + }) + + test("rejects non-DeepCode assets, duplicate assets, and malformed checksums", () => { + expect(() => + parseReleaseManifest( + JSON.stringify({ schema: 1, product: "deepcode", version, assets: [{ name: "opencode-linux-x64.tar.gz", sha256: "a".repeat(64) }] }), + ), + ).toThrow("non-DeepCode asset") + + expect(() => + parseReleaseManifest( + JSON.stringify({ + schema: 1, + product: "deepcode", + version, + assets: [ + { name: asset, sha256: "a".repeat(64) }, + { name: asset, sha256: "b".repeat(64) }, + ], + }), + ), + ).toThrow("duplicate asset") + + expect(() => + parseReleaseManifest(JSON.stringify({ schema: 1, product: "deepcode", version, assets: [{ name: asset, sha256: "invalid" }] })), + ).toThrow("invalid checksum") + }) + + test("downloads only the manifest-selected asset and verifies SHA-256", async () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const pathname = new URL(request.url).pathname + if (pathname.endsWith("/deepcode-manifest.json")) { + return Response.json({ + schema: 1, + product: "deepcode", + version, + assets: [{ name: asset, sha256: sha256(bytes) }], + }) + } + if (pathname.endsWith(`/${asset}`)) return new Response(bytes) + return new Response("missing", { status: 404 }) + }, + }) + + try { + const downloaded = await downloadVerifiedRelease(version, { + releaseBaseUrl: server.url.toString(), + platform: "linux", + architecture: "x64", + }) + expect(downloaded.asset.name).toBe(asset) + expect(downloaded.bytes).toEqual(bytes) + } finally { + server.stop(true) + } + }) + + test("rejects interrupted downloads and wrong hashes without returning an asset", async () => { + const interrupted = Bun.serve({ + port: 0, + fetch(request) { + const pathname = new URL(request.url).pathname + if (pathname.endsWith("/deepcode-manifest.json")) { + return Response.json({ + schema: 1, + product: "deepcode", + version, + assets: [{ name: asset, sha256: sha256(bytes) }], + }) + } + return new Response("partial", { status: 503 }) + }, + }) + + try { + await expect( + downloadVerifiedRelease(version, { + releaseBaseUrl: interrupted.url.toString(), + platform: "linux", + architecture: "x64", + }), + ).rejects.toThrow("HTTP 503") + } finally { + interrupted.stop(true) + } + + const wrongHash = Bun.serve({ + port: 0, + fetch(request) { + const pathname = new URL(request.url).pathname + if (pathname.endsWith("/deepcode-manifest.json")) { + return Response.json({ + schema: 1, + product: "deepcode", + version, + assets: [{ name: asset, sha256: "0".repeat(64) }], + }) + } + return new Response(bytes) + }, + }) + + try { + await expect( + downloadVerifiedRelease(version, { + releaseBaseUrl: wrongHash.url.toString(), + platform: "linux", + architecture: "x64", + }), + ).rejects.toThrow("checksum mismatch") + } finally { + wrongHash.stop(true) + } + }) + + test("restores the previous binary when post-replacement verification fails", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "deepcode-upgrade-")) + const executable = path.join(directory, "deepcode") + const candidate = path.join(directory, "candidate") + await fs.writeFile(executable, "old") + await fs.writeFile(candidate, "new") + let calls = 0 + + try { + await expect( + replaceBinaryAtomically(executable, candidate, version, { + verifyBinary: async () => { + calls += 1 + if (calls === 2) throw new Error("installed smoke failed") + }, + }), + ).rejects.toThrow("installed smoke failed") + expect(await fs.readFile(executable, "utf8")).toBe("old") + expect(await fs.access(`${executable}.previous`).then(() => true, () => false)).toBe(false) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + + test("rejects install manifests that could delete files outside .deepcode", () => { + const executable = path.join(os.tmpdir(), ".deepcode", "bin", "deepcode") + expect(() => + parseInstallManifest( + JSON.stringify({ schema: 1, product: "deepcode", version, files: [path.join(os.tmpdir(), ".opencode", "bin", "opencode")] }), + executable, + ), + ).toThrow("outside the DeepCode installation root") + }) +})