diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 9121c5f7a..2dd971627 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -101,6 +101,9 @@ jobs: - name: Build macOS binaries (Electron) run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app + - name: Verify bundled Node resource (Electron macOS) + run: node scripts/verify-bundled-node.cjs packages/electron-app/release/mac/CodeNomad.app/Contents/Resources@darwin-x64 packages/electron-app/release/mac-arm64/CodeNomad.app/Contents/Resources@darwin-arm64 + - name: Ad-hoc sign Electron macOS app bundles (seal resources) shell: bash run: | @@ -162,7 +165,7 @@ jobs: arch="arm64" fi - out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip" + out_zip="$release_root/CodeNomad-Electron-macos-${arch}-${VERSION_TO_USE}.zip" rm -f "$out_zip" echo "ditto -ck: $app -> $out_zip" ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip" @@ -177,7 +180,7 @@ jobs: tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT - zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip) + zips=(packages/electron-app/release/CodeNomad-Electron-macos-*.zip) if [ "${#zips[@]}" -eq 0 ]; then echo "No Electron macOS zip artifacts found to validate" >&2 exit 1 @@ -259,6 +262,9 @@ jobs: - name: Build Windows binaries (Electron) run: npm run build:win --workspace @neuralnomads/codenomad-electron-app + - name: Verify bundled Node resource (Electron Windows) + run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@win32-x64 + - name: Upload release assets if: ${{ inputs.upload && inputs.tag != '' }} shell: pwsh @@ -308,6 +314,9 @@ jobs: - name: Build Linux binaries (Electron) run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app + - name: Verify bundled Node resource (Electron Linux) + run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64 + - name: Upload release assets if: ${{ inputs.upload && inputs.tag != '' }} run: | @@ -364,6 +373,9 @@ jobs: - name: Prebuild (Tauri) run: npm run prebuild --workspace @codenomad/tauri-app + - name: Verify bundled Node resource (Tauri macOS x64) + run: node scripts/verify-bundled-node.cjs packages/tauri-app/src-tauri/resources@darwin-x64 + - name: Ensure tauri native binary working-directory: packages/tauri-app run: | @@ -391,7 +403,7 @@ jobs: rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then - ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip" + ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-macos-x64-${VERSION}.zip" fi - name: Upload Actions artifacts (Tauri macOS) @@ -448,6 +460,9 @@ jobs: - name: Prebuild (Tauri) run: npm run prebuild --workspace @codenomad/tauri-app + - name: Verify bundled Node resource (Tauri macOS arm64) + run: node scripts/verify-bundled-node.cjs packages/tauri-app/src-tauri/resources@darwin-arm64 + - name: Ensure tauri native binary working-directory: packages/tauri-app run: | @@ -475,7 +490,7 @@ jobs: rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then - ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip" + ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-macos-arm64-${VERSION}.zip" fi - name: Upload Actions artifacts (Tauri macOS arm64) @@ -533,6 +548,9 @@ jobs: - name: Prebuild (Tauri) run: npm run prebuild --workspace @codenomad/tauri-app + - name: Verify bundled Node resource (Tauri Windows) + run: node scripts/verify-bundled-node.cjs packages/tauri-app/src-tauri/resources@win32-x64 + - name: Ensure tauri native binary shell: bash working-directory: packages/tauri-app @@ -563,7 +581,7 @@ jobs: New-Item -ItemType Directory -Path $artifactDir | Out-Null $exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1 if ($null -ne $exe) { - $dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip") + $dest = Join-Path $artifactDir ("CodeNomad-Tauri-windows-x64-$env:VERSION.zip") Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force } @@ -634,6 +652,9 @@ jobs: - name: Prebuild (Tauri) run: npm run prebuild --workspace @codenomad/tauri-app + - name: Verify bundled Node resource (Tauri Linux) + run: node scripts/verify-bundled-node.cjs packages/tauri-app/src-tauri/resources@linux-x64 + - name: Ensure tauri native binary working-directory: packages/tauri-app run: | @@ -667,17 +688,15 @@ jobs: } appimage=$(find_one "*.AppImage") - deb=$(find_one "*.deb") - rpm=$(find_one "*.rpm") + fallback_bin="$SEARCH_ROOT/release/codenomad-tauri" - if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then - echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2 + if [ -z "$appimage" ] || [ ! -f "$fallback_bin" ]; then + echo "Missing bundle(s): appimage=${appimage:-none} binary=$fallback_bin" >&2 exit 1 fi - cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage" - cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb" - cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm" + cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.AppImage" + zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.zip" "$fallback_bin" - name: Upload Actions artifacts (Tauri Linux) if: ${{ inputs.upload_actions_artifacts }} @@ -776,12 +795,12 @@ jobs: rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" shopt -s nullglob globstar - first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1) + first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.tar.gz" \) | head -n1) fallback_bin="$SEARCH_ROOT/release/codenomad-tauri" if [ -n "$first_artifact" ]; then - zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact" + zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-arm64-${VERSION}.zip" "$first_artifact" elif [ -f "$fallback_bin" ]; then - zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin" + zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-arm64-${VERSION}.zip" "$fallback_bin" else echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2 exit 1 @@ -797,61 +816,3 @@ jobs: echo "Uploading $file" gh release upload "$TAG" "$file" --clobber done - - - build-linux-rpm: - runs-on: ubuntu-24.04 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ inputs.version }} - TAG: ${{ inputs.tag }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ inputs.ref || github.ref }} - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - - - name: Install rpm packaging dependencies - run: | - sudo apt-get update - sudo apt-get install -y rpm ruby ruby-dev build-essential - sudo gem install --no-document fpm - - - name: Set workspace versions - if: ${{ inputs.set_versions && inputs.version != '' }} - run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version - - - name: Install project dependencies - run: npm ci --workspaces --include=optional - - - name: Ensure rollup native binary - run: npm install @rollup/rollup-linux-x64-gnu --no-save - - - name: Build Linux RPM binaries - run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app - - - name: Upload RPM release assets - if: ${{ inputs.upload && inputs.tag != '' }} - run: | - set -euo pipefail - shopt -s nullglob - for file in packages/electron-app/release/*.rpm; do - [ -f "$file" ] || continue - echo "Uploading $file" - gh release upload "$TAG" "$file" --clobber - done - - - name: Upload Actions artifacts (Electron Linux RPM) - if: ${{ inputs.upload_actions_artifacts }} - uses: actions/upload-artifact@v4 - with: - name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm - path: packages/electron-app/release/*.rpm - retention-days: ${{ inputs.actions_artifacts_retention_days }} - if-no-files-found: error diff --git a/.nomadworks/nomadworks.yaml b/.nomadworks/nomadworks.yaml index c0775f09d..52e3677a7 100644 --- a/.nomadworks/nomadworks.yaml +++ b/.nomadworks/nomadworks.yaml @@ -1,5 +1,5 @@ # NomadWorks repository configuration -enabled: true +enabled: false team_mode: full defaults: diff --git a/.opencode/commands/release-notes.md b/.opencode/commands/release-notes.md index e0c7734c6..bd48f771e 100644 --- a/.opencode/commands/release-notes.md +++ b/.opencode/commands/release-notes.md @@ -3,5 +3,5 @@ description: Creates release notes agent: build --- -Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0 +Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.15.0 Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch \ No newline at end of file diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index e4602ce28..ddc29a013 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/config.json", "plugin": [ - "@neuralnomads/nomadworks@0.1.0-rc.10" + // "@neuralnomads/nomadworks@0.1.0-rc.10" ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 89d70ac4f..3a8b08cc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,3 +47,7 @@ Behavior for agents: ## Tooling Preferences - Use the `edit` tool for modifying existing files; prefer it over other editing methods. - Use the `write` tool only when creating new files from scratch. + +## Commit Message Guidelines +- When creating commits, use detailed commit messages: a concise conventional-style subject followed by body paragraphs that explain the user-visible behavior change, the implementation approach, important edge cases or platform considerations, and the validation or test coverage added. +- Prefer messages that explain why the change exists and how regressions are prevented, not just a list of touched files. diff --git a/package-lock.json b/package-lock.json index 4bbddcbb9..9b6d5197f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -29,7 +29,7 @@ "packages/ui", "packages/electron-app", "packages/tauri-app", - "packages/opencode-config" + "packages/opencode-plugin" ] } }, @@ -1575,8 +1575,8 @@ "dev": true, "license": "MIT" }, - "node_modules/@codenomad/opencode-config": { - "resolved": "packages/opencode-config", + "node_modules/@codenomad/codenomad-opencode-plugin": { + "resolved": "packages/opencode-plugin", "link": true }, "node_modules/@codenomad/tauri-app": { @@ -3228,6 +3228,43 @@ "node": ">= 8" } }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz", + "integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.7", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.92", + "@opentui/solid": ">=0.1.92" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz", + "integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==", + "license": "MIT" + }, + "node_modules/@opencode-ai/plugin/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@opencode-ai/sdk": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", @@ -4013,6 +4050,142 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz", + "integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz", + "integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz", + "integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz", + "integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz", + "integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz", + "integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz", + "integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz", + "integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz", @@ -4030,6 +4203,23 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/cli/node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz", + "integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", @@ -4057,6 +4247,19 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@thisbeyond/solid-dnd": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.5.tgz", + "integrity": "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.6.0" + }, + "peerDependencies": { + "solid-js": "^1.5" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "dev": true, @@ -8306,6 +8509,31 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -8514,6 +8742,16 @@ "node": ">= 18" } }, + "node_modules/marked-katex-extension": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.8.tgz", + "integrity": "sha512-TsV9OCHHDjVBf4IH0RSjLs4Eqsjj8HGfmVCKlimrS391EtBBxzXj2gBYdF9tY7f7oXu9tb1kHV86ExJsG3iMhw==", + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16 <0.17", + "marked": ">=4 <19" + } + }, "node_modules/matcher": { "version": "3.0.0", "dev": true, @@ -13168,7 +13406,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -13195,54 +13433,21 @@ "dev": true, "license": "MIT" }, - "packages/opencode-config": { - "name": "@codenomad/opencode-config", - "version": "0.15.0", + "packages/opencode-plugin": { + "name": "@codenomad/codenomad-opencode-plugin", + "version": "0.16.0", "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.3.7" - } - }, - "packages/opencode-config/node_modules/@opencode-ai/plugin": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz", - "integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.3.7", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.92", - "@opentui/solid": ">=0.1.92" }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "packages/opencode-config/node_modules/@opencode-ai/sdk": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz", - "integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==", - "license": "MIT" - }, - "packages/opencode-config/node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "devDependencies": { + "@types/node": "^22.18.0", + "typescript": "^5.6.3" } }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -13284,7 +13489,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -13292,7 +13497,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", @@ -13306,11 +13511,14 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.5.3", + "@thisbeyond/solid-dnd": "^0.7.5", "ansi-sequence-parser": "^1.1.3", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", + "katex": "^0.16.45", "lucide-solid": "^0.300.0", "marked": "^12.0.0", + "marked-katex-extension": "^5.1.8", "monaco-editor": "^0.52.2", "qrcode": "^1.5.3", "shiki": "^3.13.0", diff --git a/package.json b/package.json index 387e35b1b..b4337c87a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.15.0", + "version": "0.16.0", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", @@ -10,7 +10,7 @@ "packages/ui", "packages/electron-app", "packages/tauri-app", - "packages/opencode-config" + "packages/opencode-plugin" ] }, "scripts": { diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 479969cc9..a996a65f7 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.15.0", + "minServerVersion": "0.16.0", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/.gitignore b/packages/electron-app/.gitignore index 3b43e35f0..c0c6679fa 100644 --- a/packages/electron-app/.gitignore +++ b/packages/electron-app/.gitignore @@ -3,3 +3,4 @@ dist/ release/ .vite/ electron/resources/server/ +electron/resources/node/ diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index d65369459..076ec8ca3 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -10,6 +10,7 @@ interface DialogOpenRequest { title?: string defaultPath?: string filters?: Array<{ name?: string; extensions: string[] }> + multiple?: boolean } interface DialogOpenResult { @@ -47,6 +48,9 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] + if (request.mode === "file" && request.multiple) { + properties.push("multiSelections") + } const filters = request.filters?.map((filter) => ({ name: filter.name ?? "Files", diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 09263b6af..c4e01e295 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,7 +1,7 @@ import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import http from "node:http" import https from "node:https" -import { existsSync, mkdirSync } from "fs" +import { existsSync, mkdirSync, rmSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" @@ -39,6 +39,49 @@ function configureDevStoragePaths() { configureDevStoragePaths() +function configurePackagedStoragePaths() { + if (!app.isPackaged) { + return + } + + try { + const sessionDataPath = join(app.getPath("userData"), "session-data-v2") + mkdirSync(sessionDataPath, { recursive: true }) + app.setPath("sessionData", sessionDataPath) + } catch (error) { + console.warn("[electron-startup] failed to configure packaged session data path", error) + } +} + +configurePackagedStoragePaths() + +function cleanupPackagedChromiumStorage() { + if (!app.isPackaged) { + return + } + + const roots = [app.getPath("sessionData"), app.getPath("userData"), join(app.getPath("userData"), "session-data")] + const names = ["Service Worker", "QuotaManager", "QuotaManager-journal"] + + for (const root of roots) { + for (const name of names) { + const candidate = join(root, name) + if (!existsSync(candidate)) { + continue + } + + try { + rmSync(candidate, { recursive: true, force: true }) + console.info("[electron-startup] removed stale Chromium storage", candidate) + } catch (error) { + console.warn("[electron-startup] failed to remove stale Chromium storage", candidate, error) + } + } + } +} + +cleanupPackagedChromiumStorage() + const cliManager = new CliProcessManager() let mainWindow: BrowserWindow | null = null let currentCliUrl: string | null = null @@ -129,7 +172,8 @@ function isIgnorableNavigationError(error: unknown): boolean { } const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : "" - return code === "ERR_ABORTED" || code === "ERR_FAILED" + const message = "message" in error ? String((error as { message?: unknown }).message ?? "") : "" + return code === "ERR_ABORTED" || code === "ERR_FAILED" || message.includes("ERR_ABORTED") || message.includes("ERR_FAILED") } function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { @@ -430,12 +474,15 @@ function finalizeCliSwap(url: string) { } function buildRemoteWindowTitle(name: string, baseUrl: string) { - try { - const parsed = new URL(baseUrl) - return `${name} - ${parsed.host}` - } catch { - return `${name} - ${baseUrl}` - } + return `${name} - ${baseUrl}` +} + +function lockWindowTitle(window: BrowserWindow, title: string) { + window.setTitle(title) + window.webContents.on("page-title-updated", (event) => { + event.preventDefault() + window.setTitle(title) + }) } function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { @@ -464,6 +511,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st additionalArguments: ["--codenomad-window-context=remote"], }, }) + lockWindowTitle(window, title) setWindowAllowedOrigin(window, targetUrl.toString()) if (payload.skipTlsVerify) { diff --git a/packages/electron-app/electron/main/managed-node.ts b/packages/electron-app/electron/main/managed-node.ts index 700aa9ad4..31110e468 100644 --- a/packages/electron-app/electron/main/managed-node.ts +++ b/packages/electron-app/electron/main/managed-node.ts @@ -1,283 +1,53 @@ -import { dialog, app } from "electron" -import { createHash } from "node:crypto" -import fs from "node:fs" -import { createWriteStream } from "node:fs" -import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises" -import https from "node:https" -import os from "node:os" +import { app } from "electron" +import { existsSync, statSync } from "node:fs" import path from "node:path" -import { pipeline } from "node:stream/promises" -import { spawn } from "node:child_process" - -const MANAGED_NODE_VERSION = "v22.22.2" -const CONFIG_DIR = path.join(app.getPath("home"), ".config", "codenomad") interface NodeArtifactSpec { - archiveName: string - archiveRoot: string binaryRelativePath: string - url: string } function getNodeArtifactSpec(): NodeArtifactSpec { - const platform = process.platform - const arch = process.arch - - if (platform === "darwin" && arch === "x64") { - return buildTarGzSpec("darwin-x64") - } - if (platform === "darwin" && arch === "arm64") { - return buildTarGzSpec("darwin-arm64") - } - if (platform === "linux" && arch === "x64") { - return buildTarGzSpec("linux-x64") - } - if (platform === "linux" && arch === "arm64") { - return buildTarGzSpec("linux-arm64") - } - if (platform === "win32" && arch === "x64") { - return buildZipSpec("win-x64", "node.exe") - } - if (platform === "win32" && arch === "arm64") { - return buildZipSpec("win-arm64", "node.exe") + if (process.platform === "win32") { + return { binaryRelativePath: "node.exe" } } - throw new Error(`Managed Node runtime is not supported on ${platform}-${arch}.`) -} - -function buildTarGzSpec(target: string): NodeArtifactSpec { - const archiveName = `node-${MANAGED_NODE_VERSION}-${target}.tar.gz` - return { - archiveName, - archiveRoot: archiveName.replace(/\.tar\.gz$/, ""), - binaryRelativePath: path.join("bin", "node"), - url: `https://nodejs.org/dist/${MANAGED_NODE_VERSION}/${archiveName}`, - } -} - -function buildZipSpec(target: string, binaryName: string): NodeArtifactSpec { - const archiveName = `node-${MANAGED_NODE_VERSION}-${target}.zip` - return { - archiveName, - archiveRoot: archiveName.replace(/\.zip$/, ""), - binaryRelativePath: binaryName, - url: `https://nodejs.org/dist/${MANAGED_NODE_VERSION}/${archiveName}`, - } + return { binaryRelativePath: path.join("bin", "node") } } function getRuntimePlatformDir(): string { return `${process.platform}-${process.arch}` } -function getManagedNodeRoot(): string { - return path.join(CONFIG_DIR, "node", MANAGED_NODE_VERSION, getRuntimePlatformDir()) -} +function getCandidateRoots(): string[] { + const platformDir = getRuntimePlatformDir() + const roots: string[] = [] -function getManagedNodeBinaryPath(): string { - return path.join(getManagedNodeRoot(), getNodeArtifactSpec().binaryRelativePath) -} - -function fileExists(filePath: string): boolean { - try { - return fs.existsSync(filePath) - } catch { - return false + if (app.isPackaged) { + roots.push(path.join(process.resourcesPath, "node", platformDir)) } -} - -async function fetchText(url: string): Promise { - const response = await request(url) - return response.toString("utf-8") -} - -function request(url: string): Promise { - return new Promise((resolve, reject) => { - const doRequest = (target: string) => { - https - .get(target, (response) => { - const statusCode = response.statusCode ?? 0 - const redirect = response.headers.location - - if (statusCode >= 300 && statusCode < 400 && redirect) { - response.resume() - doRequest(new URL(redirect, target).toString()) - return - } - - if (statusCode < 200 || statusCode >= 300) { - response.resume() - reject(new Error(`Request failed for ${target} with status ${statusCode}`)) - return - } - const chunks: Buffer[] = [] - response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) - response.on("end", () => resolve(Buffer.concat(chunks))) - response.on("error", reject) - }) - .on("error", reject) - } + // Development fallback for local packaged-resource smoke tests. + roots.push(path.join(app.getAppPath(), "electron", "resources", "node", platformDir)) - doRequest(url) - }) + return roots } -function downloadFile(url: string, destination: string): Promise { - return new Promise((resolve, reject) => { - const doDownload = (target: string) => { - https - .get(target, (response) => { - const statusCode = response.statusCode ?? 0 - const redirect = response.headers.location - - if (statusCode >= 300 && statusCode < 400 && redirect) { - response.resume() - doDownload(new URL(redirect, target).toString()) - return - } - - if (statusCode < 200 || statusCode >= 300) { - response.resume() - reject(new Error(`Download failed for ${target} with status ${statusCode}`)) - return - } - - const output = createWriteStream(destination) - pipeline(response, output).then(() => resolve()).catch(reject) - }) - .on("error", reject) - } - - doDownload(url) - }) -} - -async function sha256File(filePath: string): Promise { - const hash = createHash("sha256") - await new Promise((resolve, reject) => { - const stream = fs.createReadStream(filePath) - stream.on("data", (chunk) => hash.update(chunk)) - stream.on("end", () => resolve()) - stream.on("error", reject) - }) - return hash.digest("hex") -} - -async function fetchExpectedSha256(archiveName: string): Promise { - const checksums = await fetchText(`https://nodejs.org/dist/${MANAGED_NODE_VERSION}/SHASUMS256.txt`) - for (const line of checksums.split(/\r?\n/)) { - const trimmed = line.trim() - if (!trimmed) continue - const [checksum, fileName] = trimmed.split(/\s+/, 2) - if (fileName === archiveName) { - return checksum - } - } - throw new Error(`Unable to find checksum for ${archiveName}.`) -} - -function runCommand(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: "ignore", shell: false }) - child.on("error", reject) - child.on("exit", (code) => { - if (code === 0) { - resolve() - } else { - reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? 1}`)) - } - }) - }) -} - -async function extractArchive(archivePath: string, destination: string): Promise { - if (archivePath.endsWith(".zip")) { - const command = process.platform === "win32" ? "powershell.exe" : "powershell" - await runCommand(command, [ - "-NoProfile", - "-NonInteractive", - "-Command", - "Expand-Archive", - "-LiteralPath", - archivePath, - "-DestinationPath", - destination, - "-Force", - ]) - return - } - - await runCommand("tar", ["-xzf", archivePath, "-C", destination]) -} - -async function promptForManagedNodeDownload(): Promise { - const result = await dialog.showMessageBox({ - type: "question", - buttons: ["Download", "Cancel"], - defaultId: 0, - cancelId: 1, - noLink: true, - title: "Download Node Runtime", - message: "CodeNomad needs its managed Node.js runtime to start the server.", - detail: `Download ${MANAGED_NODE_VERSION} for ${process.platform}-${process.arch} into ~/.config/codenomad?`, - }) - - return result.response === 0 -} - -async function installManagedNodeRuntime(): Promise { +export function ensureManagedNodeBinary(): string { const spec = getNodeArtifactSpec() - const runtimeRoot = getManagedNodeRoot() - const runtimeParent = path.dirname(runtimeRoot) - await mkdir(runtimeParent, { recursive: true }) - const tempRoot = await mkdtemp(path.join(runtimeParent, ".download-")) - const archivePath = path.join(tempRoot, spec.archiveName) - const extractRoot = path.join(tempRoot, "extract") - - try { - await mkdir(extractRoot, { recursive: true }) - - const expectedSha = await fetchExpectedSha256(spec.archiveName) - await downloadFile(spec.url, archivePath) - const actualSha = await sha256File(archivePath) - if (actualSha !== expectedSha) { - throw new Error(`Checksum mismatch for ${spec.archiveName}.`) + for (const root of getCandidateRoots()) { + const binaryPath = path.join(root, spec.binaryRelativePath) + if (!existsSync(binaryPath)) { + continue } - await extractArchive(archivePath, extractRoot) - - const extractedRoot = path.join(extractRoot, spec.archiveRoot) - const extractedBinary = path.join(extractedRoot, spec.binaryRelativePath) - if (!fileExists(extractedBinary)) { - throw new Error(`Managed Node binary missing after extraction: ${extractedBinary}`) + const stats = statSync(binaryPath) + if (stats.isFile()) { + return binaryPath } - - await rm(runtimeRoot, { recursive: true, force: true }) - await rename(extractedRoot, runtimeRoot) - - return path.join(runtimeRoot, spec.binaryRelativePath) - } finally { - await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined) - } -} - -export async function ensureManagedNodeBinary(): Promise { - const binaryPath = getManagedNodeBinaryPath() - if (fileExists(binaryPath)) { - return binaryPath - } - - const confirmed = await promptForManagedNodeDownload() - if (!confirmed) { - throw new Error("CodeNomad requires the managed Node.js runtime to start. Download was cancelled.") - } - - const installedBinary = await installManagedNodeRuntime() - const installedStats = await stat(installedBinary) - if (!installedStats.isFile()) { - throw new Error(`Managed Node binary is invalid: ${installedBinary}`) } - return installedBinary + throw new Error( + `Bundled Node runtime is missing for ${process.platform}-${process.arch}. Rebuild the desktop bundle with packaged Node resources.`, + ) } diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 236d6fc5b..a1cd35e06 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -212,11 +212,14 @@ export class CliProcessManager extends EventEmitter { this.child = child this.updateStatus({ pid: child.pid ?? undefined }) - child.stdout?.on("data", (data: Buffer) => { + const stdout = child.stdout as NodeJS.ReadableStream | undefined + const stderr = child.stderr as NodeJS.ReadableStream | undefined + + stdout?.on("data", (data: Buffer) => { this.handleStream(data.toString(), "stdout") }) - child.stderr?.on("data", (data: Buffer) => { + stderr?.on("data", (data: Buffer) => { this.handleStream(data.toString(), "stderr") }) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 4311124fe..e33e57186 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.15.0", + "version": "0.16.0", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { @@ -35,12 +35,9 @@ "build:linux-arm64": "node scripts/build.js linux-arm64", "build:linux-rpm": "node scripts/build.js linux-rpm", "build:all": "node scripts/build.js all", - "prepackage:mac": "npm run prepare:resources", - "package:mac": "electron-builder --mac", - "prepackage:win": "npm run prepare:resources", - "package:win": "electron-builder --win", - "prepackage:linux": "npm run prepare:resources", - "package:linux": "electron-builder --linux" + "package:mac": "node scripts/build.js mac", + "package:win": "node scripts/build.js win", + "package:linux": "node scripts/build.js linux" }, "dependencies": { "@neuralnomads/codenomad": "file:../server", @@ -82,8 +79,8 @@ ] }, { - "from": "../server/dist/opencode-config", - "to": "opencode-config" + "from": "../server/dist/opencode-plugin", + "to": "opencode-plugin" } ], "mac": { @@ -96,14 +93,10 @@ "category": "public.app-category.developer-tools", "target": [ { - "target": "zip", - "arch": [ - "x64", - "arm64" - ] + "target": "zip" } ], - "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}", + "artifactName": "CodeNomad-Electron-macos-${arch}-${version}.${ext}", "icon": "electron/resources/icon.icns" }, "dmg": { @@ -123,14 +116,10 @@ "win": { "target": [ { - "target": "zip", - "arch": [ - "x64", - "arm64" - ] + "target": "zip" } ], - "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}", + "artifactName": "CodeNomad-Electron-windows-${arch}-${version}.${ext}", "icon": "electron/resources/icon.ico" }, "nsis": { @@ -142,21 +131,13 @@ "linux": { "target": [ { - "target": "zip", - "arch": [ - "x64", - "arm64" - ] + "target": "zip" }, { - "target": "AppImage", - "arch": [ - "x64", - "arm64" - ] + "target": "AppImage" } ], - "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}", + "artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}", "category": "Development", "icon": "electron/resources/icon.png" } diff --git a/packages/electron-app/scripts/build.js b/packages/electron-app/scripts/build.js index 636170d84..f308a2ad5 100644 --- a/packages/electron-app/scripts/build.js +++ b/packages/electron-app/scripts/build.js @@ -16,39 +16,52 @@ const workspaceNodeModulesPath = join(workspaceRoot, "node_modules") const platforms = { mac: { - args: ["--mac", "--x64", "--arm64"], + jobs: [ + { args: ["--mac", "--x64"], nodeTarget: "darwin-x64" }, + { args: ["--mac", "--arm64"], nodeTarget: "darwin-arm64" }, + ], description: "macOS (Intel & Apple Silicon)", }, "mac-x64": { - args: ["--mac", "--x64"], + jobs: [{ args: ["--mac", "--x64"], nodeTarget: "darwin-x64" }], description: "macOS (Intel only)", }, "mac-arm64": { - args: ["--mac", "--arm64"], + jobs: [{ args: ["--mac", "--arm64"], nodeTarget: "darwin-arm64" }], description: "macOS (Apple Silicon only)", }, win: { - args: ["--win", "--x64"], + jobs: [{ args: ["--win", "--x64"], nodeTarget: "win32-x64" }], description: "Windows (x64)", }, "win-arm64": { - args: ["--win", "--arm64"], + jobs: [{ args: ["--win", "--arm64"], nodeTarget: "win32-arm64" }], description: "Windows (ARM64)", }, linux: { - args: ["--linux", "--x64"], + jobs: [{ args: ["--linux", "--x64"], nodeTarget: "linux-x64" }], description: "Linux (x64)", }, "linux-arm64": { - args: ["--linux", "--arm64"], + jobs: [{ args: ["--linux", "--arm64"], nodeTarget: "linux-arm64" }], description: "Linux (ARM64)", }, "linux-rpm": { - args: ["--linux", "rpm", "--x64", "--arm64"], + jobs: [ + { args: ["--linux", "rpm", "--x64"], nodeTarget: "linux-x64" }, + { args: ["--linux", "rpm", "--arm64"], nodeTarget: "linux-arm64" }, + ], description: "Linux RPM packages (x64 & ARM64)", }, all: { - args: ["--mac", "--win", "--linux", "--x64", "--arm64"], + jobs: [ + { args: ["--mac", "--x64"], nodeTarget: "darwin-x64" }, + { args: ["--mac", "--arm64"], nodeTarget: "darwin-arm64" }, + { args: ["--win", "--x64"], nodeTarget: "win32-x64" }, + { args: ["--win", "--arm64"], nodeTarget: "win32-arm64" }, + { args: ["--linux", "--x64"], nodeTarget: "linux-x64" }, + { args: ["--linux", "--arm64"], nodeTarget: "linux-arm64" }, + ], description: "All platforms (macOS, Windows, Linux)", }, } @@ -111,14 +124,8 @@ async function build(platform) { env: { NODE_PATH: workspaceNodeModulesPath }, }) - console.log("\nšŸ“¦ Step 1.5/3: Preparing packaged server resources...\n") - await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], { - cwd: workspaceRoot, - env: { NODE_PATH: workspaceNodeModulesPath }, - }) - console.log("\nšŸ“¦ Step 2/3: Building Electron app...\n") - await run(npmCmd, ["run", "build"]) + await run(npxCmd, ["electron-vite", "build"]) console.log("\nšŸ“¦ Step 3/3: Packaging binaries...\n") const distPath = join(appDir, "dist") @@ -126,7 +133,19 @@ async function build(platform) { throw new Error("dist/ directory not found. Build failed.") } - await run(npxCmd, ["electron-builder", "--publish=never", ...config.args]) + for (const job of config.jobs) { + console.log(`\nšŸ“¦ Preparing resources for ${job.nodeTarget}...\n`) + await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], { + cwd: workspaceRoot, + shell: false, + env: { NODE_PATH: workspaceNodeModulesPath, CODENOMAD_NODE_TARGET: job.nodeTarget }, + }) + + console.log(`\nšŸ“¦ Packaging ${job.nodeTarget}...\n`) + await run(npxCmd, ["electron-builder", "--publish=never", ...job.args], { + env: { CODENOMAD_NODE_TARGET: job.nodeTarget }, + }) + } console.log("\nāœ… Build complete!") console.log(`šŸ“ Binaries available in: ${join(appDir, "release")}\n`) diff --git a/packages/electron-app/scripts/prepare-resources.js b/packages/electron-app/scripts/prepare-resources.js index d44a4508e..27cbd58b3 100644 --- a/packages/electron-app/scripts/prepare-resources.js +++ b/packages/electron-app/scripts/prepare-resources.js @@ -3,9 +3,11 @@ import fs from "fs" import path, { join } from "path" import { spawnSync } from "child_process" +import { createRequire } from "module" import { fileURLToPath } from "url" const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const require = createRequire(import.meta.url) const appDir = join(__dirname, "..") const workspaceRoot = join(appDir, "..", "..") const serverRoot = join(appDir, "..", "server") @@ -13,6 +15,7 @@ const resourcesRoot = join(appDir, "electron", "resources") const serverDest = join(resourcesRoot, "server") const npmExecPath = process.env.npm_execpath const npmNodeExecPath = process.env.npm_node_execpath +const { prepareBundledNodeRuntime } = require(join(workspaceRoot, "scripts", "prepare-node-runtime.cjs")) const serverSources = ["dist", "public", "node_modules", "package.json"] const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json") @@ -124,6 +127,7 @@ async function main() { ensureServerDependencies() copyServerArtifacts() stripNodeModuleBins() + await prepareBundledNodeRuntime({ resourcesRoot }) } main().catch((error) => { diff --git a/packages/opencode-config/opencode.jsonc b/packages/opencode-config/opencode.jsonc deleted file mode 100644 index c3eb6a56f..000000000 --- a/packages/opencode-config/opencode.jsonc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json" -} \ No newline at end of file diff --git a/packages/opencode-config/package-lock.json b/packages/opencode-config/package-lock.json deleted file mode 100644 index 840b67473..000000000 --- a/packages/opencode-config/package-lock.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "name": "@codenomad/opencode-config", - "version": "0.15.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@codenomad/opencode-config", - "version": "0.15.0", - "license": "MIT", - "dependencies": { - "@opencode-ai/plugin": "1.14.24" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.24.tgz", - "integrity": "sha512-upzw2a9KfzIkIvvjYSPJiyV6o85D3HLmhVvAJIwV8mYWxbvi2wP2NA0hJaMp2+GZVuUl/ra8WV8kacD1CWcb4w==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.14.24", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.24.tgz", - "integrity": "sha512-hZWc1jx+gtZBM6Mff9iOMlXM1at9BbAGg0uNrQk8DuXpd8K19fu942emojdInO2zy0jC5/wWggsi7GJu7HMp/w==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json deleted file mode 100644 index b7d127b9b..000000000 --- a/packages/opencode-config/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@codenomad/opencode-config", - "version": "0.15.0", - "private": true, - "license": "MIT", - "dependencies": { - "@opencode-ai/plugin": "1.3.7" - } -} diff --git a/packages/opencode-config/README.md b/packages/opencode-plugin/README.md similarity index 55% rename from packages/opencode-config/README.md rename to packages/opencode-plugin/README.md index 28e60bd2b..16b8a8192 100644 --- a/packages/opencode-config/README.md +++ b/packages/opencode-plugin/README.md @@ -1,16 +1,16 @@ -# opencode-config +# CodeNomad OpenCode Plugin ## TLDR -Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode. +Packaged OpenCode plugin injected into every OpenCode instance that CodeNomad launches. It provides the CodeNomad bridge for local event exchange between the CLI server and OpenCode. ## What it is -A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory. +An npm-packable plugin package. Production builds ship a local `.tgz` and inject it through `OPENCODE_CONFIG_CONTENT`; dev runs reference the TypeScript plugin entry directly with a `file://` URL. ## How it works -- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`). -- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`). -- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`). -- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`). +- CodeNomad sets `OPENCODE_CONFIG_CONTENT` when spawning each OpenCode instance (`packages/server/src/workspaces/manager.ts`). +- The server packs this package during build (`packages/server/scripts/package-opencode-plugin.mjs`). +- OpenCode loads the plugin from `plugin` entries injected into the config content. +- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-plugin/plugin/lib/client.ts`). - The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`). ## Expectations @@ -25,8 +25,8 @@ A packaged config directory that CodeNomad copies into `~/.config/codenomad/open - Promote stable event shapes and version tags once the protocol settles. ## Pointers -- Plugin entry: `packages/opencode-config/plugin/codenomad.ts` -- Plugin client: `packages/opencode-config/plugin/lib/client.ts` +- Plugin entry: `packages/opencode-plugin/plugin/codenomad.ts` +- Plugin client: `packages/opencode-plugin/plugin/lib/client.ts` - Plugin server routes: `packages/server/src/server/routes/plugin.ts` - Plugin event handling: `packages/server/src/plugins/handlers.ts` - Workspace env injection: `packages/server/src/workspaces/manager.ts` diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json new file mode 100644 index 000000000..a77516bb7 --- /dev/null +++ b/packages/opencode-plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "@codenomad/codenomad-opencode-plugin", + "version": "0.16.0", + "private": true, + "license": "MIT", + "type": "module", + "main": "dist/codenomad.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json" + }, + "dependencies": { + "@opencode-ai/plugin": "1.3.7" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/opencode-config/plugin/codenomad.ts b/packages/opencode-plugin/plugin/codenomad.ts similarity index 86% rename from packages/opencode-config/plugin/codenomad.ts rename to packages/opencode-plugin/plugin/codenomad.ts index 08515dd8a..61d1827f0 100644 --- a/packages/opencode-config/plugin/codenomad.ts +++ b/packages/opencode-plugin/plugin/codenomad.ts @@ -1,10 +1,14 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" -import { createBackgroundProcessTools } from "./lib/background-process" +import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client.js" +import { createBackgroundProcessTools } from "./lib/background-process.js" let voiceModeEnabled = false -export async function CodeNomadPlugin(input: PluginInput) { +export async function CodeNomadPlugin(input: PluginInput): Promise<{ + tool: ReturnType + "chat.message": CodeNomadChatMessageHook + event: CodeNomadEventHook +}> { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory }) @@ -45,6 +49,13 @@ export async function CodeNomadPlugin(input: PluginInput) { } } +type CodeNomadChatMessageHook = ( + _input: { sessionID: string }, + output: { message: { system?: string } }, +) => Promise + +type CodeNomadEventHook = (input: { event: any }) => Promise + function buildVoiceModePrompt(): string { return [ "Voice conversation mode is enabled.", diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-plugin/plugin/lib/background-process.ts similarity index 99% rename from packages/opencode-config/plugin/lib/background-process.ts rename to packages/opencode-plugin/plugin/lib/background-process.ts index 15198288a..6840737d6 100644 --- a/packages/opencode-config/plugin/lib/background-process.ts +++ b/packages/opencode-plugin/plugin/lib/background-process.ts @@ -1,6 +1,6 @@ import path from "path" import { tool } from "@opencode-ai/plugin/tool" -import { createCodeNomadRequester, type CodeNomadConfig } from "./request" +import { createCodeNomadRequester, type CodeNomadConfig } from "./request.js" type BackgroundProcess = { id: string diff --git a/packages/opencode-config/plugin/lib/client.ts b/packages/opencode-plugin/plugin/lib/client.ts similarity index 98% rename from packages/opencode-config/plugin/lib/client.ts rename to packages/opencode-plugin/plugin/lib/client.ts index fddc49a4f..aee7a15dc 100644 --- a/packages/opencode-config/plugin/lib/client.ts +++ b/packages/opencode-plugin/plugin/lib/client.ts @@ -1,6 +1,6 @@ -import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request" +import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request.js" -export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request" +export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request.js" export function createCodeNomadClient(config: CodeNomadConfig) { const requester = createCodeNomadRequester(config) diff --git a/packages/opencode-config/plugin/lib/request.ts b/packages/opencode-plugin/plugin/lib/request.ts similarity index 100% rename from packages/opencode-config/plugin/lib/request.ts rename to packages/opencode-plugin/plugin/lib/request.ts diff --git a/packages/opencode-plugin/tsconfig.json b/packages/opencode-plugin/tsconfig.json new file mode 100644 index 000000000..09a866276 --- /dev/null +++ b/packages/opencode-plugin/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "outDir": "dist", + "rootDir": "plugin", + "types": ["node"] + }, + "include": ["plugin/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 5f97fe0c1..df79cd15f 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/packages/server/package.json b/packages/server/package.json index b47bdf292..6639b3607 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "description": "CodeNomad Server", "license": "MIT", "author": { @@ -17,10 +17,10 @@ "codenomad": "dist/bin.js" }, "scripts": { - "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config", + "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-plugin", "build:ui": "npm run build --prefix ../ui", "prepare-ui": "node ./scripts/copy-ui-dist.mjs", - "prepare-config": "node ./scripts/copy-opencode-config.mjs", + "prepare-plugin": "node ./scripts/package-opencode-plugin.mjs", "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/server/scripts/copy-opencode-config.mjs b/packages/server/scripts/copy-opencode-config.mjs deleted file mode 100644 index 5e7143956..000000000 --- a/packages/server/scripts/copy-opencode-config.mjs +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "child_process" -import { cpSync, existsSync, mkdirSync, rmSync } from "fs" -import path from "path" -import { fileURLToPath } from "url" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const cliRoot = path.resolve(__dirname, "..") -const sourceDir = path.resolve(cliRoot, "../opencode-config") -const targetDir = path.resolve(cliRoot, "dist/opencode-config") -const nodeModulesDir = path.resolve(sourceDir, "node_modules") -const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config") -const npmExecPath = process.env.npm_execpath -const npmNodeExecPath = process.env.npm_node_execpath - -if (!existsSync(sourceDir)) { - console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`) - process.exit(1) -} - -if (!existsSync(nodeModulesDir)) { - console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`) - - const npmArgs = [ - "install", - "--prefix", - sourceDir, - "--omit=dev", - "--ignore-scripts", - "--fund=false", - "--audit=false", - "--package-lock=false", - "--workspaces=false", - ] - - const env = { ...process.env, npm_config_workspaces: "false" } - - const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null - const result = npmCli - ? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env }) - : spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" }) - - if (result.status !== 0) { - if (result.error) { - console.error("[copy-opencode-config] npm install failed to start", result.error) - } - console.error("[copy-opencode-config] Failed to install opencode-config dependencies") - process.exit(result.status ?? 1) - } -} - -// npm can create a self-referential link for scoped packages on Windows. -// That link causes recursive copies (ELOOP) during bundling. -rmSync(selfLinkDir, { recursive: true, force: true }) - -rmSync(targetDir, { recursive: true, force: true }) -mkdirSync(path.dirname(targetDir), { recursive: true }) -cpSync(sourceDir, targetDir, { recursive: true }) - -console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`) diff --git a/packages/server/scripts/package-opencode-plugin.mjs b/packages/server/scripts/package-opencode-plugin.mjs new file mode 100644 index 000000000..319905476 --- /dev/null +++ b/packages/server/scripts/package-opencode-plugin.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { readdirSync, renameSync, rmSync, mkdirSync } from "fs" +import path from "path" +import { spawnSync } from "child_process" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const serverRoot = path.resolve(__dirname, "..") +const workspaceRoot = path.resolve(serverRoot, "../..") +const pluginRoot = path.resolve(serverRoot, "../opencode-plugin") +const targetDir = path.resolve(serverRoot, "dist/opencode-plugin") +const targetTarballName = "codenomad-opencode-plugin.tgz" +const pluginWorkspace = "@codenomad/codenomad-opencode-plugin" +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm" + +function run(command, args, options) { + const result = spawnSync(command, args, { + stdio: options?.capture ? ["ignore", "pipe", "inherit"] : "inherit", + shell: process.platform === "win32", + encoding: "utf8", + ...options, + }) + + if (result.error) { + console.error(`[package-opencode-plugin] ${command} failed to start`, result.error) + process.exit(1) + } + + if (result.status !== 0) { + console.error(`[package-opencode-plugin] ${command} exited with code ${result.status ?? 1}`) + process.exit(result.status ?? 1) + } + + return result.stdout ?? "" +} + +rmSync(targetDir, { recursive: true, force: true }) +mkdirSync(targetDir, { recursive: true }) + +console.log(`[package-opencode-plugin] Building ${pluginWorkspace}`) +run(npmCommand, ["run", "build", "--workspace", pluginWorkspace], { cwd: workspaceRoot }) + +console.log(`[package-opencode-plugin] Packing ${pluginWorkspace}`) +run(npmCommand, ["pack", "--pack-destination", targetDir], { cwd: pluginRoot, capture: true }) + +const tarballs = readdirSync(targetDir).filter((name) => name.endsWith(".tgz")) +if (tarballs.length !== 1) { + console.error(`[package-opencode-plugin] Expected exactly one packed plugin tarball in ${targetDir}, found ${tarballs.length}`) + process.exit(1) +} + +const packedTarball = path.join(targetDir, tarballs[0]) +const targetTarball = path.join(targetDir, targetTarballName) +if (packedTarball !== targetTarball) { + renameSync(packedTarball, targetTarball) +} + +console.log(`[package-opencode-plugin] Packed ${targetTarball}`) diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index c40d05e37..d591dba44 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -40,6 +40,16 @@ export interface WorkspaceCreateRequest { name?: string } +export interface WorkspaceCloneRequest { + repositoryUrl: string + destinationPath: string + cleanup?: boolean +} + +export interface WorkspaceCloneResponse { + path: string +} + export type WorkspaceCreateResponse = WorkspaceDescriptor export type WorkspaceListResponse = WorkspaceDescriptor[] export type WorkspaceDetailResponse = WorkspaceDescriptor @@ -204,6 +214,12 @@ export interface FileSystemCreateFolderResponse { absolutePath: string } +export interface FileSystemFileContentResponse { + path: string + contents: string + encoding: "utf-8" | "base64" +} + export const WINDOWS_DRIVES_ROOT = "__drives__" export interface WorkspaceFileResponse { @@ -211,6 +227,7 @@ export interface WorkspaceFileResponse { relativePath: string /** UTF-8 file contents; binary files should be base64 encoded by the caller. */ contents: string + encoding?: "utf-8" | "base64" } export type WorkspaceFileSearchResponse = FileSystemEntry[] @@ -246,6 +263,14 @@ export interface SideCar { updatedAt: string } +export interface PreviewSession { + token: string + sessionId: string + targetUrl: string + proxyUrl: string + createdAt: string +} + export interface BinaryRecord { id: string path: string diff --git a/packages/server/src/filesystem/__tests__/browser.test.ts b/packages/server/src/filesystem/__tests__/browser.test.ts new file mode 100644 index 000000000..968732f98 --- /dev/null +++ b/packages/server/src/filesystem/__tests__/browser.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import { FileSystemBrowser } from "../browser" +import { WINDOWS_DRIVES_ROOT } from "../../api-types" + +const tempRoots: string[] = [] + +describe("FileSystemBrowser", () => { + afterEach(() => { + for (const directory of tempRoots.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it("starts unrestricted browsing from the configured root", () => { + const rootDir = createTempRoot() + fs.mkdirSync(path.join(rootDir, "project")) + + const browser = new FileSystemBrowser({ rootDir, unrestricted: true }) + const listing = browser.browse() + + assert.equal(listing.metadata.scope, "unrestricted") + assert.equal(listing.metadata.currentPath, rootDir) + assert.equal(listing.metadata.rootPath, rootDir) + assert.equal(listing.metadata.displayPath, rootDir) + assert.equal(listing.metadata.pathKind, "absolute") + assert.ok(listing.entries.some((entry) => entry.name === "project" && entry.absolutePath === path.join(rootDir, "project"))) + }) + + it("treats dot as the configured unrestricted root", () => { + const rootDir = createTempRoot() + const browser = new FileSystemBrowser({ rootDir, unrestricted: true }) + + const listing = browser.browse(".") + + assert.equal(listing.metadata.currentPath, rootDir) + assert.equal(listing.metadata.rootPath, rootDir) + }) + + it("allows unrestricted browsing outside the configured root", () => { + const rootDir = createTempRoot() + const parentDir = path.dirname(rootDir) + const browser = new FileSystemBrowser({ rootDir, unrestricted: true }) + + const listing = browser.browse(parentDir) + + assert.equal(listing.metadata.currentPath, parentDir) + assert.equal(listing.metadata.rootPath, rootDir) + assert.ok(listing.entries.some((entry) => entry.absolutePath === rootDir)) + }) + + it("creates folders under the configured unrestricted root by default", () => { + const rootDir = createTempRoot() + const browser = new FileSystemBrowser({ rootDir, unrestricted: true }) + + const created = browser.createFolder(undefined, "created-folder") + + assert.equal(created.path, path.join(rootDir, "created-folder")) + assert.equal(created.absolutePath, path.join(rootDir, "created-folder")) + assert.equal(fs.statSync(created.absolutePath).isDirectory(), true) + }) + + it("reports the configured root for the Windows drives pseudo-root", () => { + const rootDir = createTempRoot() + const browser = new FileSystemBrowser({ rootDir, unrestricted: true, platform: "win32" }) + + const listing = browser.browse(WINDOWS_DRIVES_ROOT) + + assert.equal(listing.metadata.scope, "unrestricted") + assert.equal(listing.metadata.currentPath, WINDOWS_DRIVES_ROOT) + assert.equal(listing.metadata.rootPath, rootDir) + assert.equal(listing.metadata.pathKind, "drives") + }) +}) + +function createTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-browser-")) + tempRoots.push(root) + return fs.realpathSync(root) +} diff --git a/packages/server/src/filesystem/__tests__/search-cache.test.ts b/packages/server/src/filesystem/__tests__/search-cache.test.ts index f1facf9d1..1823c5f8f 100644 --- a/packages/server/src/filesystem/__tests__/search-cache.test.ts +++ b/packages/server/src/filesystem/__tests__/search-cache.test.ts @@ -38,12 +38,12 @@ describe("workspace search cache", () => { const workspacePath = "/tmp/workspace" refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000) - const initial = getWorkspaceCandidates(workspacePath) + const initial = getWorkspaceCandidates(workspacePath, 5_001) assert.ok(initial) assert.equal(initial[0].name, "file-a") refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000) - const refreshed = getWorkspaceCandidates(workspacePath) + const refreshed = getWorkspaceCandidates(workspacePath, 6_001) assert.ok(refreshed) assert.equal(refreshed[0].name, "file-b") }) diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index 679a88823..933637e93 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -4,6 +4,7 @@ import path from "path" import { FileSystemCreateFolderResponse, FileSystemEntry, + FileSystemFileContentResponse, FileSystemListResponse, FileSystemListingMetadata, WINDOWS_DRIVES_ROOT, @@ -12,6 +13,7 @@ import { interface FileSystemBrowserOptions { rootDir: string unrestricted?: boolean + platform?: NodeJS.Platform } interface DirectoryReadOptions { @@ -21,6 +23,7 @@ interface DirectoryReadOptions { } const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)) +const MAX_READABLE_FILE_BYTES = 5 * 1024 * 1024 export class FileSystemBrowser { private readonly root: string @@ -32,7 +35,7 @@ export class FileSystemBrowser { this.root = path.resolve(options.rootDir) this.unrestricted = Boolean(options.unrestricted) this.homeDir = os.homedir() - this.isWindows = process.platform === "win32" + this.isWindows = (options.platform ?? process.platform) === "win32" } list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] { @@ -97,6 +100,28 @@ export class FileSystemBrowser { return fs.readFileSync(resolved, "utf-8") } + readFileBase64(relativePath: string): string { + if (this.unrestricted) { + throw new Error("readFileBase64 is not available in unrestricted mode") + } + const resolved = this.toRestrictedAbsolute(relativePath) + return fs.readFileSync(resolved).toString("base64") + } + + readFileContent(targetPath: string, options?: { encoding?: "utf-8" | "base64" }): FileSystemFileContentResponse { + const encoding = options?.encoding ?? "utf-8" + const resolved = this.unrestricted ? this.resolveUnrestrictedPath(targetPath) : this.toRestrictedAbsolute(targetPath) + const stats = fs.statSync(resolved) + if (!stats.isFile()) { + throw new Error("Selected path is not a file") + } + if (stats.size > MAX_READABLE_FILE_BYTES) { + throw new Error("Selected file is too large to attach") + } + const contents = encoding === "base64" ? fs.readFileSync(resolved).toString("base64") : fs.readFileSync(resolved, "utf-8") + return { path: targetPath, contents, encoding } + } + private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse { const normalizedPath = this.normalizeRelativePath(relativePath) const absolutePath = this.toRestrictedAbsolute(normalizedPath) @@ -138,7 +163,7 @@ export class FileSystemBrowser { scope: "unrestricted", currentPath: resolvedPath, parentPath, - rootPath: this.homeDir, + rootPath: this.root, homePath: this.homeDir, displayPath: resolvedPath, pathKind: "absolute", @@ -181,7 +206,7 @@ export class FileSystemBrowser { scope: "unrestricted", currentPath: WINDOWS_DRIVES_ROOT, parentPath: undefined, - rootPath: this.homeDir, + rootPath: this.root, homePath: this.homeDir, displayPath: "Drives", pathKind: "drives", @@ -318,7 +343,7 @@ export class FileSystemBrowser { private resolveUnrestrictedPath(input: string | undefined): string { if (!input || input === "." || input === "./") { - return this.homeDir + return this.root } if (this.isWindows) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 17de82f7f..f4dc70fe4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -26,6 +26,7 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" import { SideCarManager } from "./sidecars/manager" +import { PreviewManager } from "./previews/manager" import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" @@ -340,6 +341,7 @@ async function main() { eventBus, logger: logger.child({ component: "sidecars" }), }) + const previewManager = new PreviewManager() const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, @@ -435,6 +437,7 @@ async function main() { instanceStore, speechService, sidecarManager, + previewManager, authManager, clientConnectionManager, pluginChannel, @@ -461,6 +464,7 @@ async function main() { instanceStore, speechService, sidecarManager, + previewManager, authManager, clientConnectionManager, pluginChannel, diff --git a/packages/server/src/opencode-config.ts b/packages/server/src/opencode-config.ts deleted file mode 100644 index f61cb9ca6..000000000 --- a/packages/server/src/opencode-config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { existsSync } from "fs" -import path from "path" -import { fileURLToPath } from "url" -import { createLogger } from "./logger" - -const log = createLogger({ component: "opencode-config" }) -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const devTemplateDir = path.resolve(__dirname, "../../opencode-config") -const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath -const prodTemplateDirs = [ - resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined, - path.resolve(__dirname, "opencode-config"), -].filter((dir): dir is string => Boolean(dir)) - -const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir) -const templateDir = isDevBuild - ? devTemplateDir - : prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0] - -export function getOpencodeConfigDir(): string { - if (!existsSync(templateDir)) { - throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`) - } - - if (isDevBuild) { - log.debug({ templateDir }, "Using Opencode config template directly (dev mode)") - } - - return templateDir -} diff --git a/packages/server/src/opencode-plugin.test.ts b/packages/server/src/opencode-plugin.test.ts new file mode 100644 index 000000000..dda5f9881 --- /dev/null +++ b/packages/server/src/opencode-plugin.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { buildOpencodeConfigContent } from "./opencode-plugin" + +describe("buildOpencodeConfigContent", () => { + it("creates config content with the CodeNomad plugin", () => { + const content = buildOpencodeConfigContent(undefined, "file:///plugin.tgz") + + assert.deepEqual(JSON.parse(content), { + "$schema": "https://opencode.ai/config.json", + plugin: ["file:///plugin.tgz"], + }) + }) + + it("merges with existing JSONC content", () => { + const content = buildOpencodeConfigContent( + `{ + // user plugin + "plugin": ["npm:user-plugin",], + "model": "test-model", + }`, + "file:///plugin.tgz", + ) + + assert.deepEqual(JSON.parse(content), { + "$schema": "https://opencode.ai/config.json", + plugin: ["npm:user-plugin", "file:///plugin.tgz"], + model: "test-model", + }) + }) + + it("does not duplicate the CodeNomad plugin", () => { + const content = buildOpencodeConfigContent('{"plugin":["file:///plugin.tgz"]}', "file:///plugin.tgz") + + assert.deepEqual(JSON.parse(content).plugin, ["file:///plugin.tgz"]) + }) +}) diff --git a/packages/server/src/opencode-plugin.ts b/packages/server/src/opencode-plugin.ts new file mode 100644 index 000000000..761292da5 --- /dev/null +++ b/packages/server/src/opencode-plugin.ts @@ -0,0 +1,175 @@ +import { existsSync, readdirSync } from "fs" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { createLogger } from "./logger" + +const log = createLogger({ component: "opencode-plugin" }) +const pluginPackageName = "@codenomad/codenomad-opencode-plugin" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath +const devPluginEntry = path.resolve(__dirname, "../../opencode-plugin/plugin/codenomad.ts") +const prodPluginDirs = [ + resourcesPath ? path.resolve(resourcesPath, "opencode-plugin") : undefined, + resourcesPath ? path.resolve(resourcesPath, "server/dist/opencode-plugin") : undefined, + path.resolve(__dirname, "opencode-plugin"), +].filter((dir): dir is string => Boolean(dir)) + +const isDevBuild = Boolean( + process.env.CODENOMAD_DEV ?? + process.env.CLI_UI_DEV_SERVER ?? + process.env.VITE_DEV_SERVER_URL ?? + process.env.ELECTRON_RENDERER_URL, +) +const isSourceRun = path.basename(__dirname) === "src" && existsSync(devPluginEntry) + +export function getCodeNomadPluginUrl(): string { + if (isDevBuild || isSourceRun) { + if (!existsSync(devPluginEntry)) { + throw new Error(`CodeNomad OpenCode plugin entry missing at ${devPluginEntry}`) + } + + log.debug({ pluginEntry: devPluginEntry }, "Using OpenCode plugin source directly (dev mode)") + return pathToFileURL(devPluginEntry).href + } + + for (const dir of prodPluginDirs) { + const tarball = findPluginTarball(dir) + if (tarball) { + return toNpmFileSpecifier(tarball) + } + } + + throw new Error(`CodeNomad OpenCode plugin package missing in ${prodPluginDirs.join(", ")}`) +} + +export function buildOpencodeConfigContent(existingContent: string | undefined, pluginUrl: string): string { + const config = existingContent?.trim() ? parseJsoncObject(existingContent) : {} + const existingPlugins = normalizePluginEntries(config.plugin) + if (!existingPlugins.includes(pluginUrl)) { + existingPlugins.push(pluginUrl) + } + return JSON.stringify( + { + "$schema": typeof config["$schema"] === "string" ? config["$schema"] : "https://opencode.ai/config.json", + ...config, + plugin: existingPlugins, + }, + null, + 2, + ) +} + +export function resolveExistingOpencodeConfigContent(userEnvironment: Record): string | undefined { + const userValue = normalizeConfigContentValue(userEnvironment.OPENCODE_CONFIG_CONTENT) + if (userValue) { + return userValue + } + return normalizeConfigContentValue(process.env.OPENCODE_CONFIG_CONTENT) +} + +function toNpmFileSpecifier(filePath: string): string { + return `${pluginPackageName}@file:${filePath.replace(/\\/g, "/")}` +} + +function findPluginTarball(dir: string): string | null { + if (!existsSync(dir)) { + return null + } + + const tarballs = readdirSync(dir) + .filter((name) => name.endsWith(".tgz")) + .sort() + return tarballs.length > 0 ? path.resolve(dir, tarballs[tarballs.length - 1]) : null +} + +function normalizeConfigContentValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined +} + +function parseJsoncObject(content: string): Record { + try { + const parsed = JSON.parse(stripJsonc(content)) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("OPENCODE_CONFIG_CONTENT must be a JSON object") + } + return parsed as Record + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse OPENCODE_CONFIG_CONTENT: ${reason}`) + } +} + +function normalizePluginEntries(value: unknown): string[] { + if (value === undefined) { + return [] + } + if (typeof value === "string") { + return [value] + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return [...value] + } + throw new Error("OPENCODE_CONFIG_CONTENT plugin field must be a string or string array") +} + +function stripJsonc(input: string): string { + let output = "" + let inString = false + let escape = false + + for (let index = 0; index < input.length; index += 1) { + const char = input[index] + const next = input[index + 1] + + if (escape) { + output += char + escape = false + continue + } + + if (char === "\\" && inString) { + output += char + escape = true + continue + } + + if (char === '"') { + output += char + inString = !inString + continue + } + + if (!inString && char === "/" && next === "/") { + while (index < input.length && input[index] !== "\n") { + index += 1 + } + output += "\n" + continue + } + + if (!inString && char === "/" && next === "*") { + index += 2 + while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) { + output += input[index] === "\n" ? "\n" : "" + index += 1 + } + index += 1 + continue + } + + if (!inString && char === ",") { + let lookahead = index + 1 + while (lookahead < input.length && /\s/.test(input[lookahead])) { + lookahead += 1 + } + if (input[lookahead] === "}" || input[lookahead] === "]") { + continue + } + } + + output += char + } + + return output +} diff --git a/packages/server/src/previews/manager.ts b/packages/server/src/previews/manager.ts new file mode 100644 index 000000000..7bf2396af --- /dev/null +++ b/packages/server/src/previews/manager.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "crypto" +import type { PreviewSession } from "../api-types" + +interface PreviewRecord { + token: string + sessionId: string + target: URL + createdAt: string +} + +export class PreviewManager { + private readonly previews = new Map() + + create(sessionId: string, rawUrl: string): PreviewSession { + const target = this.normalizeTargetUrl(rawUrl) + const token = randomUUID() + const record: PreviewRecord = { + token, + sessionId, + target, + createdAt: new Date().toISOString(), + } + this.previews.set(token, record) + return this.toPreviewSession(record) + } + + get(token: string): PreviewSession | undefined { + const record = this.previews.get(token) + return record ? this.toPreviewSession(record) : undefined + } + + delete(token: string): boolean { + return this.previews.delete(token) + } + + buildTargetUrl(token: string, incomingPath: string, search = ""): URL | undefined { + const record = this.previews.get(token) + if (!record) return undefined + + const publicBase = this.buildProxyBasePath(token) + let targetPath = incomingPath.startsWith(publicBase) ? incomingPath.slice(publicBase.length) : incomingPath + if (!targetPath || targetPath === "/") { + targetPath = record.target.pathname || "/" + } else if (!targetPath.startsWith("/")) { + targetPath = `/${targetPath}` + } + + return new URL(`${targetPath}${search}`, record.target.origin) + } + + buildProxyBasePath(token: string): string { + return `/previews/${encodeURIComponent(token)}` + } + + private normalizeTargetUrl(rawUrl: string): URL { + const trimmed = rawUrl.trim() + const withProtocol = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}` + const target = new URL(withProtocol) + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Error("Preview URL must use HTTP or HTTPS") + } + if (target.username || target.password) { + throw new Error("Preview URL cannot include credentials") + } + return target + } + + private toPreviewSession(record: PreviewRecord): PreviewSession { + return { + token: record.token, + sessionId: record.sessionId, + targetUrl: record.target.toString(), + proxyUrl: `${this.buildProxyBasePath(record.token)}${record.target.pathname}${record.target.search}${record.target.hash}`, + createdAt: record.createdAt, + } + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index cf7dae364..faa53d3fd 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -6,7 +6,7 @@ import fs from "fs" import { connect as connectTcp, type Socket } from "net" import path from "path" import { connect as connectTls, type TLSSocket } from "tls" -import { fetch } from "undici" +import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" @@ -28,6 +28,7 @@ import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" +import { registerPreviewRoutes } from "./routes/previews" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -39,6 +40,7 @@ import { ClientConnectionManager } from "../clients/connection-manager" import { PluginChannelManager } from "../plugins/channel" import { VoiceModeManager } from "../plugins/voice-mode" import type { SideCarManager } from "../sidecars/manager" +import type { PreviewManager } from "../previews/manager" import type { RemoteProxySessionManager } from "./remote-proxy" interface HttpServerDeps { @@ -56,6 +58,7 @@ interface HttpServerDeps { instanceStore: InstanceStore speechService: SpeechService sidecarManager: SideCarManager + previewManager: PreviewManager authManager: AuthManager clientConnectionManager: ClientConnectionManager pluginChannel: PluginChannelManager @@ -214,7 +217,7 @@ export function createHttpServer(deps: HttpServerDeps) { const session = deps.authManager.getSessionFromRequest(request) - const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") + const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") || pathname.startsWith("/previews/") if (requiresAuthForApi && !session) { // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) @@ -285,12 +288,19 @@ export function createHttpServer(deps: HttpServerDeps) { registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) + registerPreviewRoutes(app, { previewManager: deps.previewManager }) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) + registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) setupSideCarWebSocketProxy(app, { sidecarManager: deps.sidecarManager, authManager: deps.authManager, logger: proxyLogger, }) + setupPreviewWebSocketProxy(app, { + previewManager: deps.previewManager, + authManager: deps.authManager, + logger: proxyLogger, + }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, @@ -303,9 +313,9 @@ export function createHttpServer(deps: HttpServerDeps) { if (deps.uiDevServerUrl) { - setupDevProxy(app, deps.uiDevServerUrl, deps.authManager) + setupDevProxy(app, deps.uiDevServerUrl, deps.authManager, deps.previewManager, proxyLogger) } else { - setupStaticUi(app, deps.uiStaticDir, deps.authManager) + setupStaticUi(app, deps.uiStaticDir, deps.authManager, deps.previewManager, proxyLogger) } return { @@ -381,6 +391,15 @@ interface SideCarWebSocketProxyDeps extends SideCarProxyDeps { authManager: AuthManager } +interface PreviewProxyDeps { + previewManager: PreviewManager + logger: Logger +} + +interface PreviewWebSocketProxyDeps extends PreviewProxyDeps { + authManager: AuthManager +} + function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) { const proxyBaseHandler = async ( request: FastifyRequest<{ Params: { id: string } }>, @@ -412,6 +431,37 @@ function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps app.all("/sidecars/:id/*", proxyWildcardHandler) } +function registerPreviewProxyRoutes(app: FastifyInstance, deps: PreviewProxyDeps) { + const proxyBaseHandler = async ( + request: FastifyRequest<{ Params: { token: string } }>, + reply: FastifyReply, + ) => { + await proxyPreviewRequest({ + request, + reply, + previewManager: deps.previewManager, + logger: deps.logger, + pathSuffix: "", + }) + } + + const proxyWildcardHandler = async ( + request: FastifyRequest<{ Params: { token: string; "*": string } }>, + reply: FastifyReply, + ) => { + await proxyPreviewRequest({ + request, + reply, + previewManager: deps.previewManager, + logger: deps.logger, + pathSuffix: request.params["*"] ?? "", + }) + } + + app.all("/previews/:token", proxyBaseHandler) + app.all("/previews/:token/*", proxyWildcardHandler) +} + function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) { app.server.on("upgrade", (request, socket, head) => { const rawUrl = request.url ?? "/" @@ -434,6 +484,28 @@ function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocket }) } +function setupPreviewWebSocketProxy(app: FastifyInstance, deps: PreviewWebSocketProxyDeps) { + app.server.on("upgrade", (request, socket, head) => { + const rawUrl = request.url ?? "/" + const parsed = parsePreviewUpgradePath(rawUrl) + if (!parsed) { + return + } + + void proxyPreviewWebSocketUpgrade({ + request, + socket: socket as Socket, + head, + token: parsed.token, + incomingPath: parsed.pathname, + search: parsed.search, + previewManager: deps.previewManager, + authManager: deps.authManager, + logger: deps.logger, + }) + }) +} + function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() @@ -770,7 +842,13 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) { return trimmed.length === 0 ? "/" : `/${trimmed}` } -function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { +function setupStaticUi( + app: FastifyInstance, + uiDir: string, + authManager: AuthManager, + previewManager: PreviewManager, + logger: Logger, +) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") return @@ -781,6 +859,14 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan return } + app.addHook("preHandler", (request, reply, done) => { + const session = authManager.getSessionFromRequest(request) + if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) { + return + } + done() + }) + app.register(fastifyStatic, { root: uiDir, prefix: "/", @@ -797,6 +883,10 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan } const session = authManager.getSessionFromRequest(request) + if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) { + return + } + if (!session && wantsHtml(request)) { reply.redirect("/login") return @@ -810,7 +900,13 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan }) } -function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) { +function setupDevProxy( + app: FastifyInstance, + upstreamBase: string, + authManager: AuthManager, + previewManager: PreviewManager, + logger: Logger, +) { app.log.info({ upstreamBase }, "Proxying UI requests to development server") app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { const url = request.raw.url ?? "" @@ -820,6 +916,10 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: } const session = authManager.getSessionFromRequest(request) + if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) { + return + } + if (!session && wantsHtml(request)) { reply.redirect("/login") return @@ -829,6 +929,49 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: }) } +function proxyPreviewFallbackFromReferer( + request: FastifyRequest, + reply: FastifyReply, + previewManager: PreviewManager, + logger: Logger, +): boolean { + const rawUrl = request.raw.url ?? request.url ?? "" + const pathname = rawUrl.split("?")[0] ?? "" + if (!isPreviewFallbackPath(pathname)) { + return false + } + + const refererHeader = request.headers.referer ?? request.headers.referrer + const referer = Array.isArray(refererHeader) ? refererHeader[0] : refererHeader + if (!referer) { + return false + } + + const parsed = parsePreviewUpgradePath(referer) + if (!parsed) { + return false + } + + void proxyPreviewAssetRequest({ + request, + reply, + previewManager, + logger, + token: parsed.token, + }) + return true +} + +function isPreviewFallbackPath(pathname: string): boolean { + if (!pathname || pathname === "/") return false + if (pathname.startsWith("/api/") || pathname === "/api") return false + if (pathname.startsWith("/workspaces/")) return false + if (pathname.startsWith("/sidecars/")) return false + if (pathname.startsWith("/previews/")) return false + if (pathname.startsWith("/auth/") || pathname === "/login") return false + return true +} + async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) { try { const targetUrl = new URL(request.raw.url ?? "/", upstreamBase) @@ -873,6 +1016,82 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record { + const sanitized = sanitizeSideCarProxyRequestHeaders( + headers as Record, + targetOrigin, + ) + const result: Record = {} + for (const [key, value] of Object.entries(sanitized)) { + if (!value) continue + if (key.toLowerCase() === "cookie") continue + result[key] = Array.isArray(value) ? value.join(",") : value + } + return result +} + +function headersToRecord(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + result[key.toLowerCase()] = value + }) + return result +} + +function getHeaderValue(headers: Record, key: string): string | undefined { + const value = headers[key.toLowerCase()] + return Array.isArray(value) ? value[0] : value +} + +function shouldForwardRequestBody(method: string): boolean { + const normalized = method.toUpperCase() + return normalized !== "GET" && normalized !== "HEAD" +} + +function isHtmlContentType(contentType: string): boolean { + const normalized = contentType.toLowerCase() + return normalized.includes("text/html") || normalized.includes("application/xhtml+xml") +} + +function isCssContentType(contentType: string): boolean { + return contentType.toLowerCase().includes("text/css") +} + +function rewritePreviewBodyUrls(body: string, publicBase: string, kind: "html" | "css"): string { + if (kind === "css") { + return rewriteCssPreviewUrls(body, publicBase) + } + + return rewriteCssPreviewUrls( + body + .replace(/\b(src|href|action|poster|data)=(["'])\/(?!\/)([^"']*)\2/gi, (_match, attr: string, quote: string, pathValue: string) => { + return `${attr}=${quote}${publicBase}/${pathValue}${quote}` + }) + .replace(/\bsrcset=(["'])([^"']*)\1/gi, (_match, quote: string, value: string) => { + return `srcset=${quote}${rewriteSrcsetPreviewUrls(value, publicBase)}${quote}` + }), + publicBase, + ) +} + +function rewriteCssPreviewUrls(body: string, publicBase: string): string { + return body.replace(/url\((\s*)(["']?)\/(?!\/)([^"')]+)\2(\s*)\)/gi, (_match, before: string, quote: string, pathValue: string, after: string) => { + return `url(${before}${quote}${publicBase}/${pathValue}${quote}${after})` + }) +} + +function rewriteSrcsetPreviewUrls(value: string, publicBase: string): string { + return value + .split(",") + .map((entry) => { + const trimmed = entry.trimStart() + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) return entry + const leading = entry.slice(0, entry.length - trimmed.length) + return `${leading}${publicBase}${trimmed}` + }) + .join(",") +} + async function proxySideCarRequest(args: { request: FastifyRequest reply: FastifyReply @@ -897,14 +1116,154 @@ async function proxySideCarRequest(args: { const targetUrl = `${targetOrigin}${targetPath}` args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar") - await args.reply.from(targetUrl, { - rewriteRequestHeaders: (_originalRequest, headers) => - sanitizeSideCarProxyRequestHeaders(headers as Record, targetOrigin), + await proxyTargetRequest({ + reply: args.reply, + logger: args.logger, + targetUrl, + targetOrigin, + logContext: { sidecarId: sidecar.id }, + errorMessage: "SideCar proxy failed", rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode), + }) +} + +async function proxyPreviewRequest(args: { + request: FastifyRequest + reply: FastifyReply + previewManager: PreviewManager + logger: Logger + pathSuffix?: string +}) { + const token = (args.request.params as { token?: string }).token ?? "" + const preview = args.previewManager.get(token) + if (!preview) { + args.reply.code(404).send({ error: "Preview not found" }) + return + } + + const rawUrl = args.request.raw.url ?? args.request.url ?? "" + const queryIndex = rawUrl.indexOf("?") + const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : "" + const pathSuffix = args.pathSuffix ?? "" + const requestPath = pathSuffix ? `${args.previewManager.buildProxyBasePath(token)}/${pathSuffix.replace(/^\/+/, "")}` : args.previewManager.buildProxyBasePath(token) + const targetUrl = args.previewManager.buildTargetUrl(token, requestPath, search) + if (!targetUrl) { + args.reply.code(404).send({ error: "Preview not found" }) + return + } + + args.logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying request to preview") + await proxyPreviewTargetRequest({ + request: args.request, + reply: args.reply, + logger: args.logger, + targetUrl: targetUrl.toString(), + targetOrigin: targetUrl.origin, + publicBase: args.previewManager.buildProxyBasePath(token), + logContext: { previewToken: token }, + errorMessage: "Preview proxy failed", + rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, token, targetUrl.origin), + }) +} + +async function proxyPreviewAssetRequest(args: { + request: FastifyRequest + reply: FastifyReply + previewManager: PreviewManager + logger: Logger + token: string +}) { + const rawUrl = args.request.raw.url ?? args.request.url ?? "" + const queryIndex = rawUrl.indexOf("?") + const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : "" + const pathname = rawUrl.split("?")[0] ?? "/" + const targetUrl = args.previewManager.buildTargetUrl(args.token, pathname, search) + if (!targetUrl) { + args.reply.code(404).send({ error: "Preview not found" }) + return + } + + args.logger.debug({ previewToken: args.token, targetUrl: targetUrl.toString() }, "Proxying preview fallback asset") + await proxyPreviewTargetRequest({ + request: args.request, + reply: args.reply, + logger: args.logger, + targetUrl: targetUrl.toString(), + targetOrigin: targetUrl.origin, + publicBase: args.previewManager.buildProxyBasePath(args.token), + logContext: { previewToken: args.token, previewFallback: true }, + errorMessage: "Preview proxy failed", + rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, args.token, targetUrl.origin), + }) +} + +async function proxyPreviewTargetRequest(args: { + request: FastifyRequest + reply: FastifyReply + logger: Logger + targetUrl: string + targetOrigin: string + publicBase: string + logContext: Record + errorMessage: string + rewriteHeaders: (headers: Record) => Record +}) { + try { + const response = await fetch(args.targetUrl, { + method: args.request.method, + headers: buildFetchProxyHeaders(args.request.headers, args.targetOrigin), + body: shouldForwardRequestBody(args.request.method) ? (args.request.raw as any) : undefined, + duplex: shouldForwardRequestBody(args.request.method) ? "half" : undefined, + redirect: "manual", + } as any) + + const headers = args.rewriteHeaders(headersToRecord(response.headers)) + const contentType = getHeaderValue(headers, "content-type") ?? response.headers.get("content-type") ?? "" + delete headers["content-length"] + delete headers["content-encoding"] + + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) args.reply.header(key, value) + } + args.reply.code(response.status) + + if (!response.body || args.request.method === "HEAD") { + args.reply.send() + return + } + + if (isHtmlContentType(contentType) || isCssContentType(contentType)) { + const text = await response.text() + args.reply.send(rewritePreviewBodyUrls(text, args.publicBase, isCssContentType(contentType) ? "css" : "html")) + return + } + + args.reply.send(Buffer.from(await response.arrayBuffer())) + } catch (error) { + args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage) + if (!args.reply.sent) { + args.reply.code(502).send({ error: args.errorMessage }) + } + } +} + +async function proxyTargetRequest(args: { + reply: FastifyReply + logger: Logger + targetUrl: string + targetOrigin: string + logContext: Record + errorMessage: string + rewriteHeaders: (headers: Record) => Record +}) { + await args.reply.from(args.targetUrl, { + rewriteRequestHeaders: (_originalRequest, headers) => + sanitizeSideCarProxyRequestHeaders(headers as Record, args.targetOrigin), + rewriteHeaders: args.rewriteHeaders, onError: (reply, { error }) => { - args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request") + args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage) if (!reply.sent) { - reply.code(502).send({ error: "SideCar proxy failed" }) + reply.code(502).send({ error: args.errorMessage }) } }, }) @@ -934,6 +1293,30 @@ function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: } } +function parsePreviewUpgradePath(rawUrl: string): { token: string; pathname: string; search: string } | null { + let parsed: URL + try { + parsed = new URL(rawUrl, "http://localhost") + } catch { + return null + } + + const match = parsed.pathname.match(/^\/previews\/([^/]+)(?:\/.*)?$/) + if (!match) { + return null + } + + try { + return { + token: decodeURIComponent(match[1] ?? ""), + pathname: parsed.pathname, + search: parsed.search, + } + } catch { + return null + } +} + async function proxySideCarWebSocketUpgrade(args: { request: import("http").IncomingMessage socket: Socket @@ -969,6 +1352,71 @@ async function proxySideCarWebSocketUpgrade(args: { const targetUrl = new URL(`${targetOrigin}${targetPath}`) logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar") + proxyTargetWebSocketUpgrade({ + request, + socket, + head, + targetUrl, + logger, + logContext: { sidecarId }, + proxyLabel: "SideCar", + }) +} + +async function proxyPreviewWebSocketUpgrade(args: { + request: import("http").IncomingMessage + socket: Socket + head: Buffer + token: string + incomingPath: string + search: string + previewManager: PreviewManager + authManager: AuthManager + logger: Logger +}) { + const { request, socket, head, token, incomingPath, search, previewManager, authManager, logger } = args + + if (!isWebSocketUpgradeRequest(request)) { + rejectUpgrade(socket, 400, "Bad Request") + return + } + + const session = authManager.getSessionFromHeaders(request.headers) + if (!session) { + rejectUpgrade(socket, 401, "Unauthorized") + return + } + + const targetUrl = previewManager.buildTargetUrl(token, incomingPath, search) + if (!targetUrl) { + rejectUpgrade(socket, 404, "Not Found") + return + } + + logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying websocket to preview") + proxyTargetWebSocketUpgrade({ + request, + socket, + head, + targetUrl, + logger, + logContext: { previewToken: token }, + proxyLabel: "preview", + stripCookies: true, + }) +} + +function proxyTargetWebSocketUpgrade(args: { + request: import("http").IncomingMessage + socket: Socket + head: Buffer + targetUrl: URL + logger: Logger + logContext: Record + proxyLabel: string + stripCookies?: boolean +}) { + const { request, socket, head, targetUrl, logger, logContext, proxyLabel, stripCookies } = args const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl) const closeBoth = () => { @@ -981,7 +1429,7 @@ async function proxySideCarWebSocketUpgrade(args: { } upstream.once("error", (error) => { - logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket") + logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to proxy ${proxyLabel} websocket`) rejectUpgrade(socket, 502, "Bad Gateway") if (!upstream.destroyed) { upstream.destroy() @@ -989,7 +1437,7 @@ async function proxySideCarWebSocketUpgrade(args: { }) socket.once("error", (error) => { - logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored") + logger.debug({ ...logContext, err: error }, `${proxyLabel} websocket client socket errored`) if (!upstream.destroyed) { upstream.destroy() } @@ -997,14 +1445,14 @@ async function proxySideCarWebSocketUpgrade(args: { upstream.once(readyEvent, () => { try { - upstream.write(buildSideCarWebSocketRequest(request, targetUrl)) + upstream.write(buildSideCarWebSocketRequest(request, targetUrl, { stripCookies })) if (head.length > 0) { upstream.write(head) } upstream.pipe(socket) socket.pipe(upstream) } catch (error) { - logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade") + logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to forward ${proxyLabel} websocket upgrade`) closeBoth() } }) @@ -1040,7 +1488,11 @@ function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSock } } -function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string { +function buildSideCarWebSocketRequest( + request: import("http").IncomingMessage, + targetUrl: URL, + options?: { stripCookies?: boolean }, +): string { const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}` const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n` const headerLines: string[] = [] @@ -1053,6 +1505,7 @@ function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, t if (!key || value === undefined) continue const lower = key.toLowerCase() if (blockedHeaders.has(lower)) continue + if (options?.stripCookies && lower === "cookie") continue if (lower === "origin") { headerLines.push(`Origin: ${targetUrl.origin}\r\n`) continue @@ -1121,6 +1574,42 @@ function rewriteSideCarResponseHeaders( return next } +function rewritePreviewResponseHeaders( + headers: Record, + token: string, + targetOrigin: string, +) { + const next = { ...headers } + delete next["x-frame-options"] + delete next["content-security-policy"] + delete next["content-security-policy-report-only"] + delete next["set-cookie"] + delete next["set-cookie2"] + + const locationHeader = next.location + const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader + if (!location) { + return next + } + + const publicBase = `/previews/${encodeURIComponent(token)}` + if (location.startsWith("/")) { + next.location = `${publicBase}${location}` + return next + } + + try { + const parsed = new URL(location) + if (parsed.origin === targetOrigin) { + next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}` + } + } catch { + // Relative redirects should continue to resolve against the current preview path. + } + + return next +} + function sanitizeSideCarProxyRequestHeaders( headers: Record, targetOrigin: string, diff --git a/packages/server/src/server/routes/filesystem.ts b/packages/server/src/server/routes/filesystem.ts index 4f5895f49..418157208 100644 --- a/packages/server/src/server/routes/filesystem.ts +++ b/packages/server/src/server/routes/filesystem.ts @@ -16,6 +16,11 @@ const FilesystemCreateFolderSchema = z.object({ name: z.string(), }) +const FilesystemFileContentQuerySchema = z.object({ + path: z.string(), + encoding: z.enum(["utf-8", "base64"]).optional(), +}) + export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/filesystem", async (request, reply) => { const query = FilesystemQuerySchema.parse(request.query ?? {}) @@ -51,4 +56,14 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) reply.code(400).type("text/plain").send((error as Error).message) } }) + + app.get("/api/filesystem/files/content", async (request, reply) => { + const query = FilesystemFileContentQuerySchema.parse(request.query ?? {}) + + try { + return deps.fileSystemBrowser.readFileContent(query.path, { encoding: query.encoding }) + } catch (error) { + reply.code(400).type("text/plain").send((error as Error).message) + } + }) } diff --git a/packages/server/src/server/routes/previews.ts b/packages/server/src/server/routes/previews.ts new file mode 100644 index 000000000..fa42a5ac5 --- /dev/null +++ b/packages/server/src/server/routes/previews.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { PreviewSession } from "../../api-types" +import type { PreviewManager } from "../../previews/manager" + +interface RouteDeps { + previewManager: PreviewManager +} + +const PreviewCreateSchema = z.object({ + sessionId: z.string().trim().min(1), + url: z.string().trim().min(1), +}) + +export function registerPreviewRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post("/api/previews", async (request, reply): Promise => { + try { + const body = PreviewCreateSchema.parse(request.body ?? {}) + return deps.previewManager.create(body.sessionId, body.url) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to create preview" } + } + }) + + app.delete<{ Params: { token: string } }>("/api/previews/:token", async (request, reply) => { + const removed = deps.previewManager.delete(request.params.token) + if (!removed) { + reply.code(404) + return { error: "Preview not found" } + } + reply.code(204) + }) +} diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 9f2a68a2e..517367f56 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status" import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations" +import { cloneGitRepository, isGitCloneError } from "../../workspaces/git-clone" import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees" import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory" @@ -15,12 +16,19 @@ const WorkspaceCreateSchema = z.object({ name: z.string().optional(), }) +const WorkspaceCloneSchema = z.object({ + repositoryUrl: z.string().trim().min(1, "Repository URL is required"), + destinationPath: z.string().trim().min(1, "Destination path is required"), + cleanup: z.boolean().optional(), +}) + const WorkspaceFilesQuerySchema = z.object({ path: z.string().optional(), }) const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), + encoding: z.enum(["utf-8", "base64"]).optional(), }) const WorkspaceFileContentBodySchema = z.object({ @@ -69,6 +77,17 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { } }) + app.post("/api/workspaces/clone", async (request, reply) => { + try { + const body = WorkspaceCloneSchema.parse(request.body ?? {}) + const result = await cloneGitRepository(body) + reply.code(201) + return result + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) if (!workspace) { @@ -117,7 +136,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }>("/api/workspaces/:id/files/content", async (request, reply) => { try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) - return deps.workspaceManager.readFile(request.params.id, query.path) + return deps.workspaceManager.readFile(request.params.id, query.path, { encoding: query.encoding }) } catch (error) { return handleWorkspaceError(error, reply) } @@ -264,6 +283,10 @@ async function resolveGitWorktreeDirectory( function handleWorkspaceError(error: unknown, reply: FastifyReply) { + if (isGitCloneError(error)) { + reply.code(error.statusCode) + return { error: error.message } + } if (isGitMutationError(error)) { reply.code(error.statusCode) return { error: error.message } diff --git a/packages/server/src/workspaces/__tests__/git-clone.test.ts b/packages/server/src/workspaces/__tests__/git-clone.test.ts new file mode 100644 index 000000000..8798d48ac --- /dev/null +++ b/packages/server/src/workspaces/__tests__/git-clone.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { homedir, tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { cloneGitRepository } from "../git-clone" + +function createBareRepository(repoPath: string): void { + execFileSync("git", ["init", "--bare", repoPath], { stdio: "ignore" }) +} + +async function expectCloneError( + callback: () => Promise, + expectedStatusCode: number, + messagePattern: RegExp, +): Promise { + await assert.rejects(callback, (error: unknown) => { + assert.equal(typeof error, "object") + assert.equal((error as { statusCode?: number }).statusCode, expectedStatusCode) + assert.match(String((error as { message?: string }).message ?? ""), messagePattern) + return true + }) +} + +describe("cloneGitRepository", () => { + it("clones into a missing destination", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + const destinationPath = path.join(temp, "cloned-repo") + + try { + createBareRepository(sourceRepo) + + const result = await cloneGitRepository({ + repositoryUrl: sourceRepo, + destinationPath, + }) + + assert.equal(result.path, destinationPath) + assert.equal(existsSync(path.join(destinationPath, ".git")), true) + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("rejects non-empty destinations when cleanup is not enabled", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + const destinationPath = path.join(temp, "existing-destination") + const sentinelPath = path.join(destinationPath, "keep.txt") + + try { + createBareRepository(sourceRepo) + mkdirSync(destinationPath, { recursive: true }) + writeFileSync(sentinelPath, "keep") + + await expectCloneError( + () => cloneGitRepository({ repositoryUrl: sourceRepo, destinationPath }), + 409, + /Destination folder is not empty/, + ) + + assert.equal(readFileSync(sentinelPath, "utf8"), "keep") + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("preserves the existing destination when cleanup is enabled but clone fails", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const missingRepo = path.join(temp, "missing.git") + const destinationPath = path.join(temp, "existing-destination") + const sentinelPath = path.join(destinationPath, "keep.txt") + + try { + mkdirSync(destinationPath, { recursive: true }) + writeFileSync(sentinelPath, "keep") + + await expectCloneError( + () => cloneGitRepository({ repositoryUrl: missingRepo, destinationPath, cleanup: true }), + 409, + /does not appear to be a git repository|does not exist|not found/i, + ) + + assert.equal(readFileSync(sentinelPath, "utf8"), "keep") + assert.equal(existsSync(destinationPath), true) + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("replaces the existing destination only after a successful cleanup clone", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + const destinationPath = path.join(temp, "existing-destination") + const sentinelPath = path.join(destinationPath, "keep.txt") + + try { + createBareRepository(sourceRepo) + mkdirSync(destinationPath, { recursive: true }) + writeFileSync(sentinelPath, "keep") + + const result = await cloneGitRepository({ + repositoryUrl: sourceRepo, + destinationPath, + cleanup: true, + }) + + assert.equal(result.path, destinationPath) + assert.equal(existsSync(path.join(destinationPath, ".git")), true) + assert.equal(existsSync(sentinelPath), false) + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("rejects filesystem root destinations", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + + try { + createBareRepository(sourceRepo) + + await expectCloneError( + () => + cloneGitRepository({ + repositoryUrl: sourceRepo, + destinationPath: path.parse(process.cwd()).root, + cleanup: true, + }), + 400, + /filesystem root/, + ) + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("rejects home directory destinations", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + + try { + createBareRepository(sourceRepo) + + await expectCloneError( + () => + cloneGitRepository({ + repositoryUrl: sourceRepo, + destinationPath: homedir(), + cleanup: true, + }), + 400, + /home folder/, + ) + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + + it( + "supports destinations directly under a Windows drive root", + { skip: process.platform !== "win32" }, + async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-clone-")) + const sourceRepo = path.join(temp, "source.git") + const root = path.parse(process.cwd()).root + const destinationPath = path.join(root, `codenomad-git-clone-root-${Date.now()}-${Math.random().toString(36).slice(2)}`) + + try { + createBareRepository(sourceRepo) + rmSync(destinationPath, { recursive: true, force: true }) + + const result = await cloneGitRepository({ + repositoryUrl: sourceRepo, + destinationPath, + }) + + assert.equal(result.path, destinationPath) + assert.equal(existsSync(destinationPath), true) + } finally { + rmSync(destinationPath, { recursive: true, force: true }) + rmSync(temp, { recursive: true, force: true }) + } + }, + ) +}) diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index b33aec6a8..7b829ac75 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -54,12 +54,12 @@ describe("buildWindowsSpawnSpec", () => { { cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, env: { - OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), CODENOMAD_INSTANCE_ID: "workspace-123", OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/worktrees/root/instance", OPENCODE_SERVER_PASSWORD: "secret", }, - propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"], + propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"], }, ) @@ -76,23 +76,47 @@ describe("buildWindowsSpawnSpec", () => { "0", ]) assert.equal(spec.cwd, undefined) - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") }) - it("upgrades existing WSLENV path entries to include /p", () => { + it("preserves non-path OPENCODE_CONFIG_CONTENT WSLENV entries", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve"], { env: { - OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`, - WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), + WSLENV: "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u", }, - propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"], + propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID"], }, ) - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u") + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u") + }) + + it("rewrites packaged plugin paths for WSL before launching", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + plugin: [ + "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz", + ], + }), + }, + propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT"], + }, + ) + + assert.equal(spec.command, "wsl.exe") + assert.equal(spec.env?.CODENOMAD_OPENCODE_PLUGIN_WSL_PATH, String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\codenomad-opencode-plugin.tgz`) + assert.match(spec.env?.OPENCODE_CONFIG_CONTENT ?? "", /__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__/) + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/p") + assert.deepEqual(spec.args.slice(0, 4), ["--distribution", "Ubuntu", "--exec", "sh"]) + assert.match(spec.args[5] ?? "", /CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/) }) it("propagates inherited known path variables even when they are not explicitly requested", () => { diff --git a/packages/server/src/workspaces/git-clone.ts b/packages/server/src/workspaces/git-clone.ts new file mode 100644 index 000000000..b0eeb48a0 --- /dev/null +++ b/packages/server/src/workspaces/git-clone.ts @@ -0,0 +1,179 @@ +import fs from "fs" +import path from "path" +import { spawn } from "child_process" +import { homedir } from "os" + +class GitCloneError extends Error { + statusCode: number + + constructor(message: string, statusCode = 400) { + super(message) + this.name = "GitCloneError" + this.statusCode = statusCode + } +} + +function formatErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + return fallback +} + +function normalizePathForComparison(filePath: string): string { + const resolvedPath = path.resolve(filePath) + const rootPath = path.parse(resolvedPath).root + const trimmedPath = resolvedPath === rootPath ? rootPath : resolvedPath.replace(/[\\/]+$/, "") + return process.platform === "win32" ? trimmedPath.toLowerCase() : trimmedPath +} + +function assertDestinationPathIsSafe(destinationPath: string): void { + const normalizedDestinationPath = normalizePathForComparison(destinationPath) + const normalizedRootPath = normalizePathForComparison(path.parse(destinationPath).root) + if (normalizedDestinationPath === normalizedRootPath) { + throw new GitCloneError("Destination path cannot be a filesystem root", 400) + } + + const normalizedHomePath = normalizePathForComparison(homedir()) + if (normalizedDestinationPath === normalizedHomePath) { + throw new GitCloneError("Destination path cannot be the home folder", 400) + } +} + +function createSiblingUniquePath(destinationPath: string, label: string): string { + const parentPath = path.dirname(destinationPath) + const baseName = path.basename(destinationPath) + return path.join(parentPath, `${baseName}.${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`) +} + +function runGitClone(repositoryUrl: string, destinationPath: string): Promise { + return new Promise((resolve, reject) => { + const parentPath = path.dirname(destinationPath) + const child = spawn("git", ["clone", "--", repositoryUrl, destinationPath], { + cwd: parentPath, + stdio: ["ignore", "pipe", "pipe"], + }) + + let stderr = "" + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString() + }) + + child.once("error", (error) => { + reject(new GitCloneError(error.message || "Failed to start git clone", 500)) + }) + + child.once("close", (code) => { + if (code === 0) { + resolve() + return + } + reject(new GitCloneError(stderr.trim() || `git clone failed with code ${code}`, 409)) + }) + }) +} + +function assertDestinationIsUsable(destinationPath: string, cleanup: boolean): void { + if (!fs.existsSync(destinationPath)) return + + const stat = fs.statSync(destinationPath) + if (!stat.isDirectory()) { + throw new GitCloneError("Destination path exists and is not a folder", 409) + } + + if (cleanup) { + return + } + + const entries = fs.readdirSync(destinationPath) + if (entries.length > 0) { + throw new GitCloneError("Destination folder is not empty", 409) + } +} + +function ensureDestinationParentExists(destinationPath: string): void { + const parentPath = path.dirname(destinationPath) + if (fs.existsSync(parentPath)) return + + fs.mkdirSync(parentPath, { recursive: true }) +} + +async function replaceDestinationAfterSuccessfulClone(repositoryUrl: string, destinationPath: string): Promise { + const tempClonePath = createSiblingUniquePath(destinationPath, "clone") + let backupPath: string | null = null + let preserveBackup = false + + try { + await runGitClone(repositoryUrl, tempClonePath) + + backupPath = createSiblingUniquePath(destinationPath, "backup") + fs.renameSync(destinationPath, backupPath) + + try { + fs.renameSync(tempClonePath, destinationPath) + } catch (error) { + try { + fs.renameSync(backupPath, destinationPath) + backupPath = null + } catch (restoreError) { + preserveBackup = true + throw new GitCloneError( + `Failed to replace clone destination and restore previous contents: ${formatErrorMessage(restoreError, "restore failed")}`, + 500, + ) + } + + throw new GitCloneError(`Failed to replace clone destination: ${formatErrorMessage(error, "rename failed")}`, 500) + } + + fs.rmSync(backupPath, { recursive: true, force: true }) + backupPath = null + } catch (error) { + if (error instanceof GitCloneError) { + throw error + } + + throw new GitCloneError(`Failed to prepare clone destination: ${formatErrorMessage(error, "unknown error")}`, 500) + } finally { + if (backupPath && !preserveBackup && fs.existsSync(backupPath)) { + fs.rmSync(backupPath, { recursive: true, force: true }) + } + if (fs.existsSync(tempClonePath)) { + fs.rmSync(tempClonePath, { recursive: true, force: true }) + } + } +} + +export function isGitCloneError(error: unknown): error is GitCloneError { + return error instanceof GitCloneError +} + +export async function cloneGitRepository(params: { + repositoryUrl: string + destinationPath: string + cleanup?: boolean +}): Promise<{ path: string }> { + const repositoryUrl = params.repositoryUrl.trim() + const requestedDestinationPath = params.destinationPath.trim() + + if (!repositoryUrl) { + throw new GitCloneError("Repository URL is required", 400) + } + if (!path.isAbsolute(requestedDestinationPath)) { + throw new GitCloneError("Destination path must be absolute", 400) + } + + const destinationPath = path.resolve(requestedDestinationPath) + assertDestinationPathIsSafe(destinationPath) + ensureDestinationParentExists(destinationPath) + const destinationExists = fs.existsSync(destinationPath) + assertDestinationIsUsable(destinationPath, Boolean(params.cleanup)) + + if (destinationExists && params.cleanup) { + await replaceDestinationAfterSuccessfulClone(repositoryUrl, destinationPath) + } else { + await runGitClone(repositoryUrl, destinationPath) + } + + return { path: destinationPath } +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 063c2cbb9..cd1933b2f 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -10,7 +10,11 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" -import { getOpencodeConfigDir } from "../opencode-config.js" +import { + buildOpencodeConfigContent, + getCodeNomadPluginUrl, + resolveExistingOpencodeConfigContent, +} from "../opencode-plugin.js" import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, @@ -37,12 +41,12 @@ interface WorkspaceRecord extends WorkspaceDescriptor {} export class WorkspaceManager { private readonly workspaces = new Map() private readonly runtime: WorkspaceRuntime - private readonly opencodeConfigDir: string + private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() constructor(private readonly options: WorkspaceManagerOptions) { this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) - this.opencodeConfigDir = getOpencodeConfigDir() + this.codeNomadPluginUrl = getCodeNomadPluginUrl() } list(): WorkspaceDescriptor[] { @@ -72,14 +76,16 @@ export class WorkspaceManager { return searchWorkspaceFiles(workspace.path, query, options) } - readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse { + readFile(workspaceId: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): WorkspaceFileResponse { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) - const contents = browser.readFile(relativePath) + const encoding = options?.encoding ?? "utf-8" + const contents = encoding === "base64" ? browser.readFileBase64(relativePath) : browser.readFile(relativePath) return { workspaceId, relativePath, contents, + encoding, } } @@ -123,6 +129,10 @@ export class WorkspaceManager { const serverConfig = this.options.settings.getOwner("config", "server") const envVars = (serverConfig as any)?.environmentVariables const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} + const opencodeConfigContent = buildOpencodeConfigContent( + resolveExistingOpencodeConfigContent(userEnvironment), + this.codeNomadPluginUrl, + ) const serverBaseUrl = this.options.getServerBaseUrl() const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") @@ -138,7 +148,7 @@ export class WorkspaceManager { const environment = { ...userEnvironment, - OPENCODE_CONFIG_DIR: this.opencodeConfigDir, + OPENCODE_CONFIG_CONTENT: opencodeConfigContent, CODENOMAD_INSTANCE_ID: id, CODENOMAD_BASE_URL: serverBaseUrl, ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 8add4aa48..f40dcdb02 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -6,7 +6,13 @@ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i -const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"]) +const CODENOMAD_PLUGIN_PACKAGE_NAME = "@codenomad/codenomad-opencode-plugin" +const WSL_PLUGIN_PATH_ENV = "CODENOMAD_OPENCODE_PLUGIN_WSL_PATH" +const WSL_PLUGIN_PATH_PLACEHOLDER = "__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__" +const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp( + `(${escapeRegex(CODENOMAD_PLUGIN_PACKAGE_NAME)}@file:)([A-Za-z]:[^"\\r\\n]+?\\.tgz)`, +) +const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV]) export interface SpawnSpec { command: string @@ -187,6 +193,8 @@ export function probeBinaryVersion(binaryPath: string): { function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec { const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined + const env = buildWslEnvironment(options.env, options.propagateEnvKeys) + const shouldTranslatePluginPath = Boolean(env?.[WSL_PLUGIN_PATH_ENV]) if (options.cwd && !workingDirectory) { throw new Error( `Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`, @@ -194,14 +202,14 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn } const wslArgs = ["--distribution", wslPath.distro] - const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows" + const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows" || shouldTranslatePluginPath if (!shouldWrapWithShell && workingDirectory?.kind === "linux") { wslArgs.push("--cd", workingDirectory.path) } if (shouldWrapWithShell) { - const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker) + const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker, shouldTranslatePluginPath) wslArgs.push( "--exec", "sh", @@ -224,12 +232,16 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn command: "wsl.exe", args: wslArgs, options: {}, - env: buildWslEnvironment(options.env, options.propagateEnvKeys), + env, wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker }, } } -function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string { +function buildWslLaunchScript( + workingDirectory: WslWorkingDirectory | undefined, + pidMarker: string | undefined, + translatePluginPath: boolean, +): string { const steps: string[] = [] if (pidMarker) { @@ -244,6 +256,12 @@ function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, steps.push("shift") } + if (translatePluginPath) { + steps.push( + `if [ -n "$${WSL_PLUGIN_PATH_ENV}" ] && [ -n "$OPENCODE_CONFIG_CONTENT" ]; then escaped_plugin_path=$(printf '%s' "$${WSL_PLUGIN_PATH_ENV}" | sed 's/[\\&|]/\\\\&/g'); OPENCODE_CONFIG_CONTENT=$(printf '%s' "$OPENCODE_CONFIG_CONTENT" | sed "s|${WSL_PLUGIN_PATH_PLACEHOLDER}|$escaped_plugin_path|g"); export OPENCODE_CONFIG_CONTENT; unset ${WSL_PLUGIN_PATH_ENV}; fi`, + ) + } + steps.push('exec "$@"') return steps.join(" && ") } @@ -266,17 +284,19 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey return env } + const next = { ...env } + rewriteOpencodePluginPathForWsl(next) + const keysToPropagate = Array.from( new Set([ - ...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined), - ...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined), + ...(propagateEnvKeys ?? []).filter((key) => next[key] !== undefined), + ...Array.from(WSL_PATH_ENV_KEYS).filter((key) => next[key] !== undefined), ]), ) if (keysToPropagate.length === 0) { - return env + return next } - const next = { ...env } const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0) const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry])) @@ -293,6 +313,22 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey return next } +function rewriteOpencodePluginPathForWsl(env: NodeJS.ProcessEnv) { + const content = env.OPENCODE_CONFIG_CONTENT + if (!content) { + return + } + + const match = content.match(CODENOMAD_PLUGIN_FILE_SPEC_REGEX) + const hostPath = match?.[2] + if (!hostPath) { + return + } + + env.OPENCODE_CONFIG_CONTENT = content.replace(hostPath, WSL_PLUGIN_PATH_PLACEHOLDER) + env[WSL_PLUGIN_PATH_ENV] = path.win32.normalize(hostPath) +} + function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string { if (!requiresPathTranslation) { return entry @@ -305,3 +341,7 @@ function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): str return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p` } + +function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 372789d38..828f003dc 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -47,15 +47,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -506,12 +497,11 @@ dependencies = [ [[package]] name = "codenomad-tauri" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "base64 0.22.1", "dirs 5.0.1", - "flate2", "keepawake", "libc", "parking_lot", @@ -521,8 +511,6 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", - "tar", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -533,7 +521,6 @@ dependencies = [ "webkit2gtk", "which", "windows-sys 0.59.0", - "zip", ] [[package]] @@ -783,17 +770,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -1143,17 +1119,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2264,10 +2229,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -2748,7 +2710,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -2981,12 +2943,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" version = "1.8.0" @@ -3354,15 +3310,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -4029,7 +3976,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -4244,17 +4191,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "target-lexicon" version = "0.12.16" @@ -6216,16 +6152,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - [[package]] name = "xkeysym" version = "0.2.1" @@ -6396,41 +6322,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap 2.13.0", - "memchr", - "thiserror 2.0.18", - "zopfli", -] - [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zvariant" version = "5.10.0" diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 31c0192ed..62944e2db 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.15.0", + "version": "0.16.0", "private": true, "license": "MIT", "scripts": { diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js index 8afe2b79e..62c3daaea 100644 --- a/packages/tauri-app/scripts/prebuild.js +++ b/packages/tauri-app/scripts/prebuild.js @@ -11,6 +11,8 @@ const uiRoot = path.resolve(root, "..", "ui") const uiDist = path.resolve(uiRoot, "src", "renderer", "dist") const serverDest = path.resolve(root, "src-tauri", "resources", "server") const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading") +const resourcesRoot = path.resolve(root, "src-tauri", "resources") +const { prepareBundledNodeRuntime } = require(path.join(workspaceRoot, "scripts", "prepare-node-runtime.cjs")) const sources = ["dist", "public", "node_modules", "package.json"] @@ -18,6 +20,8 @@ const serverInstallCommand = "npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false" const serverDevInstallCommand = "npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" +const pluginDevInstallCommand = + "npm install --workspace @codenomad/codenomad-opencode-plugin --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" const uiDevInstallCommand = "npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad" @@ -43,6 +47,12 @@ const serverBuildDependencyPaths = [ path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"), ] +const pluginRoot = path.resolve(root, "..", "opencode-plugin") +const pluginBuildDependencyPaths = [ + path.join(pluginRoot, "node_modules", "typescript", "package.json"), + path.join(pluginRoot, "node_modules", "@types", "node", "package.json"), +] + const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite") async function ensureMonacoAssets() { @@ -116,6 +126,19 @@ function ensureServerDevDependencies() { }) } +function ensurePluginDevDependencies() { + if (pluginBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) { + return + } + + console.log("[prebuild] ensuring OpenCode plugin build dependencies...") + execSync(pluginDevInstallCommand, { + cwd: workspaceRoot, + stdio: "inherit", + env: envWithRootBin, + }) +} + function ensureServerDependencies() { if (fs.existsSync(braceExpansionPath)) { return @@ -300,6 +323,7 @@ function copyUiLoadingAssets() { ;(async () => { ensureServerDevDependencies() + ensurePluginDevDependencies() ensureUiDevDependencies() await ensureMonacoAssets() ensureRollupPlatformBinary() @@ -311,6 +335,7 @@ function copyUiLoadingAssets() { copyServerArtifacts() stripNodeModuleBins() copyUiLoadingAssets() + await prepareBundledNodeRuntime({ resourcesRoot }) })().catch((err) => { console.error("[prebuild] failed:", err) process.exit(1) diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 30d0b660f..f464f23b7 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codenomad-tauri" -version = "0.15.0" +version = "0.16.0" edition = "2021" license = "MIT" @@ -27,10 +27,6 @@ tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" url = "2" tauri-plugin-notification = "2" -flate2 = "1" -sha2 = "0.10" -tar = "0.4" -zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] } diff --git a/packages/tauri-app/src-tauri/build.rs b/packages/tauri-app/src-tauri/build.rs index d860e1e6a..8c4baa63e 100644 --- a/packages/tauri-app/src-tauri/build.rs +++ b/packages/tauri-app/src-tauri/build.rs @@ -1,3 +1,25 @@ fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by Cargo"); + let manifest_path = std::path::Path::new(&manifest_dir); + let bundled_resources = std::path::Path::new(&out_dir) + .ancestors() + .nth(3) + .expect("OUT_DIR points inside target//build//out") + .join("resources"); + let resources_root = manifest_path.join("resources"); + let resources_node = resources_root.join("node"); + std::fs::create_dir_all(&resources_node).expect("create resources/node placeholder"); + + // Tauri copies resources additively, so clear the old output first. + if bundled_resources.exists() { + std::fs::remove_dir_all(&bundled_resources).expect("clean bundled resources output"); + } + + println!("cargo:rerun-if-changed={}", manifest_path.join("tauri.conf.json").display()); + println!("cargo:rerun-if-changed={}", resources_root.join("node").display()); + println!("cargo:rerun-if-changed={}", resources_root.join("server").display()); + println!("cargo:rerun-if-changed={}", resources_root.join("ui-loading").display()); + tauri_build::build() } diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 50e9b0fee..e44828f55 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,4 +1,4 @@ -use crate::managed_node::ensure_managed_node_binary; +use crate::managed_node::resolve_bundled_node_binary; use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; @@ -1079,7 +1079,7 @@ impl CliEntry { entry, runner: Runner::Node, runner_path: None, - node_binary: ensure_managed_node_binary(app)?, + node_binary: resolve_bundled_node_binary()?, node_args: vec!["--experimental-specifier-resolution=node".to_string()], }); } @@ -1215,10 +1215,7 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option { } fn resolve_prod_entry(_app: &AppHandle) -> Option { - let base = workspace_root(); - let mut candidates = vec![base - .as_ref() - .map(|p| p.join("packages/server/dist/bin.js"))]; + let mut candidates = Vec::new(); if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { @@ -1236,6 +1233,12 @@ fn resolve_prod_entry(_app: &AppHandle) -> Option { } } + let base = workspace_root(); + candidates.push( + base.as_ref() + .map(|p| p.join("packages/server/dist/bin.js")), + ); + first_existing(candidates) } diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index fd05dbb89..43fccc43a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -3,9 +3,9 @@ #[allow(dead_code)] mod cert_manager; mod cli_manager; -mod managed_node; #[cfg(target_os = "linux")] mod linux_tls; +mod managed_node; use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; @@ -17,7 +17,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; -use tauri::webview::Webview; +use tauri::webview::{PageLoadEvent, Webview}; use tauri::{ AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, }; @@ -55,6 +55,7 @@ pub struct AppState { pub remote_proxy_sessions: Mutex>, pub remote_skip_tls_verify: Mutex>, pub remote_tls_handlers: Mutex>, + pub remote_titles: Mutex>, } #[derive(Debug, Deserialize)] @@ -227,26 +228,38 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { false } +fn apply_remote_window_title(app_handle: &AppHandle, window_label: &str) { + let Some(title) = app_handle + .state::() + .remote_titles + .lock() + .ok() + .and_then(|titles| titles.get(window_label).cloned()) + else { + return; + }; + + if let Some(window) = app_handle.get_webview_window(window_label) { + let _ = window.set_title(&title); + } +} + async fn open_remote_window_impl( app: AppHandle, payload: RemoteWindowPayload, ) -> Result<(), String> { - let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str()); + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; let label = format!("remote-{}", payload.id); - let title = format!( - "{} - {}", - payload.name, - Url::parse(&payload.base_url) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .unwrap_or_else(|| payload.base_url.clone()) - ); + let title = format!("{} - {}", payload.name, payload.base_url); let window_url = parsed.clone(); - let allow_linux_tls_certificate = - parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); + let allow_linux_tls_certificate = parsed.scheme() == "https" + && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); app.state::() .remote_origins @@ -258,6 +271,11 @@ async fn open_remote_window_impl( .lock() .map_err(|err| err.to_string())? .insert(label.clone(), allow_linux_tls_certificate); + app.state::() + .remote_titles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), title.clone()); let replaced_session = { let state = app.state::(); @@ -281,8 +299,9 @@ async fn open_remote_window_impl( #[cfg(target_os = "linux")] linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?; - let _ = existing.navigate(window_url.clone()); let _ = existing.set_title(&title); + let _ = existing.navigate(window_url.clone()); + apply_remote_window_title(&app, &label); let _ = existing.show(); let _ = existing.unminimize(); let _ = existing.set_focus(); @@ -290,25 +309,27 @@ async fn open_remote_window_impl( } #[cfg(target_os = "linux")] - let initial_url = if linux_tls::should_bootstrap_tls_navigation( - &window_url, - allow_linux_tls_certificate, - ) { - Url::parse("about:blank").map_err(|err| err.to_string())? - } else { - window_url.clone() - }; + let initial_url = + if linux_tls::should_bootstrap_tls_navigation(&window_url, allow_linux_tls_certificate) { + Url::parse("about:blank").map_err(|err| err.to_string())? + } else { + window_url.clone() + }; #[cfg(not(target_os = "linux"))] let initial_url = window_url.clone(); - let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone())) - .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) - .title(title) - .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0) - .build() - .map_err(|err| err.to_string())?; + let window = WebviewWindowBuilder::new( + &app, + label.clone(), + WebviewUrl::External(initial_url.clone()), + ) + .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) + .title(title) + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .build() + .map_err(|err| err.to_string())?; #[cfg(target_os = "linux")] { @@ -336,6 +357,9 @@ async fn open_remote_window_impl( if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { handlers.remove(&label_for_cleanup); } + if let Ok(mut titles) = app_handle.state::().remote_titles.lock() { + titles.remove(&label_for_cleanup); + } } }); @@ -364,7 +388,10 @@ fn needs_local_certificate_install() -> Result { async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { #[cfg(not(target_os = "linux"))] { - let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str()); + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; if payload.proxy_session_id.is_some() && parsed.scheme() == "https" { let local_cert = cert_manager::ensure_local_cert().map_err(|err| { @@ -542,6 +569,15 @@ fn main() { remote_proxy_sessions: Mutex::new(HashMap::new()), remote_skip_tls_verify: Mutex::new(HashMap::new()), remote_tls_handlers: Mutex::new(HashSet::new()), + remote_titles: Mutex::new(HashMap::new()), + }) + .on_page_load(|webview, payload| { + if matches!( + payload.event(), + PageLoadEvent::Started | PageLoadEvent::Finished + ) { + apply_remote_window_title(&webview.app_handle(), webview.label()); + } }) .setup(|app| { set_windows_app_user_model_id(); diff --git a/packages/tauri-app/src-tauri/src/managed_node.rs b/packages/tauri-app/src-tauri/src/managed_node.rs index 6329a8130..091f8dd6c 100644 --- a/packages/tauri-app/src-tauri/src/managed_node.rs +++ b/packages/tauri-app/src-tauri/src/managed_node.rs @@ -1,90 +1,49 @@ use anyhow::anyhow; -use dirs::home_dir; -use flate2::read::GzDecoder; -use reqwest::blocking::Client; -use sha2::{Digest, Sha256}; -use std::fs::{self, File}; -use std::io::{self, Read}; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::thread; -use std::time::{SystemTime, UNIX_EPOCH}; -use tar::Archive; -use tauri::{AppHandle, Runtime}; -use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; -use zip::ZipArchive; -const MANAGED_NODE_VERSION: &str = "v22.22.2"; +pub fn resolve_bundled_node_binary() -> anyhow::Result { + let relative_binary = binary_relative_path(); + let platform_dir = platform_dir_name()?; -struct NodeArtifactSpec { - archive_name: &'static str, - archive_root: &'static str, - binary_relative_path: &'static str, -} - -pub fn ensure_managed_node_binary(app: &AppHandle) -> anyhow::Result { - let runtime_root = managed_node_root()?; - let spec = artifact_spec()?; - let binary_path = runtime_root.join(spec.binary_relative_path); - if binary_path.is_file() { - return Ok(binary_path.to_string_lossy().into_owned()); - } - - if !prompt_to_download(app) { - return Err(anyhow!( - "CodeNomad requires the managed Node.js runtime to start. Download was cancelled." - )); + for root in candidate_resource_roots() { + let binary_path = root.join("node").join(&platform_dir).join(relative_binary); + if binary_path.is_file() { + return Ok(binary_path.to_string_lossy().into_owned()); + } } - install_managed_node_runtime(&runtime_root, &spec)?; - - if !binary_path.is_file() { - return Err(anyhow!( - "Managed Node binary missing after installation: {}", - binary_path.display() - )); - } + Err(anyhow!( + "Bundled Node runtime is missing for {}. Rebuild the desktop bundle with packaged Node resources.", + platform_dir + )) +} - #[cfg(unix)] - { - let mut permissions = fs::metadata(&binary_path)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&binary_path, permissions)?; +fn binary_relative_path() -> &'static Path { + if cfg!(target_os = "windows") { + Path::new("node.exe") + } else { + Path::new("bin/node") } - - Ok(binary_path.to_string_lossy().into_owned()) } -fn prompt_to_download(app: &AppHandle) -> bool { - let app = app.clone(); - thread::spawn(move || { - app.dialog() - .message(format!( - "CodeNomad needs its managed Node.js runtime to start the server. Download {} for {}-{} into ~/.config/codenomad?", - MANAGED_NODE_VERSION, - platform_label(), - rust_arch_label().unwrap_or("unknown") - )) - .title("Download Node Runtime") - .buttons(MessageDialogButtons::OkCancelCustom( - "Download".into(), - "Cancel".into(), - )) - .kind(MessageDialogKind::Info) - .blocking_show() - }) - .join() - .unwrap_or(false) -} +fn candidate_resource_roots() -> Vec { + let mut candidates = Vec::new(); -fn managed_node_root() -> anyhow::Result { - Ok(config_dir()?.join("node").join(MANAGED_NODE_VERSION).join(platform_dir_name()?)) -} + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("resources")); + candidates.push(dir.join("../Resources")); + candidates.push(dir.join("../Resources/resources")); + + let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; + for root in linux_resource_roots { + candidates.push(root.clone()); + candidates.push(root.join("resources")); + } + } + } -fn config_dir() -> anyhow::Result { - let home = home_dir().ok_or_else(|| anyhow!("Unable to resolve the user home directory."))?; - Ok(home.join(".config").join("codenomad")) + candidates } fn platform_dir_name() -> anyhow::Result { @@ -103,197 +62,8 @@ fn rust_arch_label() -> anyhow::Result<&'static str> { match std::env::consts::ARCH { "x86_64" => Ok("x64"), "aarch64" => Ok("arm64"), - other => Err(anyhow!("Managed Node runtime is not supported on architecture '{other}'.")), - } -} - -fn artifact_spec() -> anyhow::Result { - let arch = rust_arch_label()?; - match (std::env::consts::OS, arch) { - ("macos", "x64") => Ok(NodeArtifactSpec { - archive_name: "node-v22.22.2-darwin-x64.tar.gz", - archive_root: "node-v22.22.2-darwin-x64", - binary_relative_path: "bin/node", - }), - ("macos", "arm64") => Ok(NodeArtifactSpec { - archive_name: "node-v22.22.2-darwin-arm64.tar.gz", - archive_root: "node-v22.22.2-darwin-arm64", - binary_relative_path: "bin/node", - }), - ("linux", "x64") => Ok(NodeArtifactSpec { - archive_name: "node-v22.22.2-linux-x64.tar.gz", - archive_root: "node-v22.22.2-linux-x64", - binary_relative_path: "bin/node", - }), - ("linux", "arm64") => Ok(NodeArtifactSpec { - archive_name: "node-v22.22.2-linux-arm64.tar.gz", - archive_root: "node-v22.22.2-linux-arm64", - binary_relative_path: "bin/node", - }), - ("windows", "x64") => Ok(NodeArtifactSpec { - archive_name: "node-v22.22.2-win-x64.zip", - archive_root: "node-v22.22.2-win-x64", - binary_relative_path: "node.exe", - }), - ("windows", "arm64") => Ok(NodeArtifactSpec { - archive_name: "node-v22.22.2-win-arm64.zip", - archive_root: "node-v22.22.2-win-arm64", - binary_relative_path: "node.exe", - }), - (os, arch) => Err(anyhow!("Managed Node runtime is not supported on {os}-{arch}.")), - } -} - -fn install_managed_node_runtime(runtime_root: &Path, spec: &NodeArtifactSpec) -> anyhow::Result<()> { - let runtime_parent = runtime_root - .parent() - .ok_or_else(|| anyhow!("Managed Node runtime path is invalid."))?; - fs::create_dir_all(runtime_parent)?; - - let temp_root = runtime_parent.join(format!( - ".download-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0) - )); - - if temp_root.exists() { - fs::remove_dir_all(&temp_root).ok(); - } - fs::create_dir_all(&temp_root)?; - - let archive_path = temp_root.join(spec.archive_name); - let extract_root = temp_root.join("extract"); - fs::create_dir_all(&extract_root)?; - - let result = (|| { - let expected_sha = fetch_expected_sha(spec.archive_name)?; - download_file(spec.archive_name, &archive_path)?; - - let actual_sha = sha256_file(&archive_path)?; - if actual_sha != expected_sha { - return Err(anyhow!("Checksum mismatch for {}.", spec.archive_name)); - } - - extract_archive(&archive_path, &extract_root)?; - - let extracted_root = extract_root.join(spec.archive_root); - let extracted_binary = extracted_root.join(spec.binary_relative_path); - if !extracted_binary.is_file() { - return Err(anyhow!( - "Managed Node binary missing after extraction: {}", - extracted_binary.display() - )); - } - - if runtime_root.exists() { - fs::remove_dir_all(runtime_root)?; - } - fs::rename(&extracted_root, runtime_root)?; - Ok(()) - })(); - - fs::remove_dir_all(&temp_root).ok(); - result -} - -fn fetch_expected_sha(archive_name: &str) -> anyhow::Result { - let url = format!("https://nodejs.org/dist/{MANAGED_NODE_VERSION}/SHASUMS256.txt"); - let response = Client::builder() - .build()? - .get(url) - .send()? - .error_for_status()?; - let body = response.text()?; - - for line in body.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let mut parts = trimmed.split_whitespace(); - let checksum = parts.next(); - let file_name = parts.next(); - if let (Some(checksum), Some(file_name)) = (checksum, file_name) { - if file_name == archive_name { - return Ok(checksum.to_string()); - } - } + other => Err(anyhow!( + "Bundled Node runtime is not supported on architecture '{other}'." + )), } - - Err(anyhow!("Unable to find checksum for {archive_name}.")) -} - -fn download_file(archive_name: &str, destination: &Path) -> anyhow::Result<()> { - let url = format!("https://nodejs.org/dist/{MANAGED_NODE_VERSION}/{archive_name}"); - let mut response = Client::builder() - .build()? - .get(url) - .send()? - .error_for_status()?; - let mut output = File::create(destination)?; - io::copy(&mut response, &mut output)?; - Ok(()) -} - -fn sha256_file(path: &Path) -> anyhow::Result { - let mut file = File::open(path)?; - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 8192]; - - loop { - let read = file.read(&mut buffer)?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - - Ok(format!("{:x}", hasher.finalize())) -} - -fn extract_archive(archive_path: &Path, destination: &Path) -> anyhow::Result<()> { - if archive_path.extension().and_then(|value| value.to_str()) == Some("zip") { - extract_zip(archive_path, destination) - } else { - extract_tar_gz(archive_path, destination) - } -} - -fn extract_tar_gz(archive_path: &Path, destination: &Path) -> anyhow::Result<()> { - let file = File::open(archive_path)?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - archive.unpack(destination)?; - Ok(()) -} - -fn extract_zip(archive_path: &Path, destination: &Path) -> anyhow::Result<()> { - let file = File::open(archive_path)?; - let mut archive = ZipArchive::new(file)?; - - for index in 0..archive.len() { - let mut entry = archive.by_index(index)?; - let relative_path = entry - .enclosed_name() - .map(|path| path.to_path_buf()) - .ok_or_else(|| anyhow!("Zip archive contains an invalid path."))?; - let output_path = destination.join(relative_path); - - if entry.is_dir() { - fs::create_dir_all(&output_path)?; - continue; - } - - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; - } - - let mut output = File::create(&output_path)?; - io::copy(&mut entry, &mut output)?; - } - - Ok(()) } diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index f059b0f10..7011ded16 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodeNomad", - "version": "0.15.0", + "version": "0.16.0", "identifier": "ai.neuralnomads.codenomad.client", "build": { "beforeDevCommand": "npm run dev:bootstrap", @@ -72,6 +72,7 @@ } }, "resources": [ + "resources/node", "resources/server", "resources/ui-loading" ], diff --git a/packages/ui/package.json b/packages/ui/package.json index b96c7e012..bf7293ae9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.15.0", + "version": "0.16.0", "private": true, "license": "MIT", "type": "module", @@ -22,11 +22,14 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.5.3", + "@thisbeyond/solid-dnd": "^0.7.5", "ansi-sequence-parser": "^1.1.3", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", + "katex": "^0.16.45", "lucide-solid": "^0.300.0", "marked": "^12.0.0", + "marked-katex-extension": "^5.1.8", "monaco-editor": "^0.52.2", "qrcode": "^1.5.3", "shiki": "^3.13.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index a93c5e6c4..2aa430fcd 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,6 +34,7 @@ import { import { useConfig } from "./stores/preferences" import { createInstance, + getExistingInstanceForFolder, instances, stopInstance, disconnectedInstance, @@ -64,6 +65,7 @@ import { ensureActiveAppTab, getAdjacentAppTabId, getAppTabById, + moveAppTab, selectAppTab, selectInstanceTab, selectSidecarTab, @@ -79,6 +81,7 @@ const App: Component = () => { recordWorkspaceLaunch, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleAutoCleanupBlankSessions, toggleUsageMetrics, @@ -93,6 +96,11 @@ const App: Component = () => { const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) + const [alreadyOpenFolderChoice, setAlreadyOpenFolderChoice] = createSignal<{ + folderPath: string + binaryPath: string + instanceId: string + } | null>(null) const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -256,15 +264,24 @@ const App: Component = () => { const launchErrorMessage = () => launchError()?.message ?? "" - async function handleSelectFolder(folderPath: string, binaryPath?: string) { + async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) { if (!folderPath) { return } - setIsSelectingFolder(true) const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" + recordWorkspaceLaunch(folderPath, selectedBinary) + clearLaunchError() + + if (!options?.forceNew) { + const existingInstance = getExistingInstanceForFolder(folderPath) + if (existingInstance) { + setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id }) + return + } + } + + setIsSelectingFolder(true) try { - recordWorkspaceLaunch(folderPath, selectedBinary) - clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) selectInstanceTab(instanceId) setShowFolderSelection(false) @@ -283,6 +300,26 @@ const App: Component = () => { } } + function dismissAlreadyOpenFolderChoice() { + setAlreadyOpenFolderChoice(null) + } + + function switchToAlreadyOpenFolder() { + const choice = alreadyOpenFolderChoice() + if (!choice) return + setAlreadyOpenFolderChoice(null) + selectInstanceTab(choice.instanceId) + setShowFolderSelection(false) + log.info("Selected existing instance", { instanceId: choice.instanceId, folderPath: choice.folderPath }) + } + + function openAnotherFolderInstance() { + const choice = alreadyOpenFolderChoice() + if (!choice) return + setAlreadyOpenFolderChoice(null) + void handleSelectFolder(choice.folderPath, choice.binaryPath, { forceNew: true }) + } + function handleLaunchErrorClose() { clearLaunchError() } @@ -410,6 +447,7 @@ const App: Component = () => { toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleUsageMetrics, togglePromptSubmitOnEnter, @@ -541,6 +579,7 @@ const App: Component = () => { onSelect={selectAppTab} onClose={(tabId) => void handleCloseAppTab(tabId)} onNew={handleNewInstanceRequest} + onMoveTab={moveAppTab} /> @@ -616,6 +655,30 @@ const App: Component = () => { setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> + + !open && dismissAlreadyOpenFolderChoice()}> + + + + + {t("folderSelection.recent.alreadyOpenTitle")} + + + {t("folderSelection.recent.alreadyOpenMessage")} + + +
+ + +
+
+
+
+
diff --git a/packages/ui/src/components/action-overflow-menu.tsx b/packages/ui/src/components/action-overflow-menu.tsx new file mode 100644 index 000000000..a66a25f3f --- /dev/null +++ b/packages/ui/src/components/action-overflow-menu.tsx @@ -0,0 +1,85 @@ +import { DropdownMenu } from "@kobalte/core/dropdown-menu" +import { For, Show, createSignal, onCleanup, type JSXElement } from "solid-js" +import { MoreHorizontal } from "lucide-solid" + +export interface ActionOverflowMenuItem { + key: string + label: string + icon?: JSXElement + disabled?: boolean + destructive?: boolean + onSelect: () => void | Promise + onMouseEnter?: () => void + onMouseLeave?: () => void +} + +interface ActionOverflowMenuProps { + items: ActionOverflowMenuItem[] + label: string + triggerClass?: string + minItems?: number +} + +export default function ActionOverflowMenu(props: ActionOverflowMenuProps) { + const [hoveredItem, setHoveredItem] = createSignal(null) + const enabledItems = () => props.items.filter((item) => !item.disabled) + const hasItems = () => props.items.length >= (props.minItems ?? 1) + const clearHoveredItem = () => { + const item = hoveredItem() + if (!item) return + item.onMouseLeave?.() + setHoveredItem(null) + } + + onCleanup(clearHoveredItem) + + return ( + + { if (!open) clearHoveredItem() }}> + + + + + + + {(item) => ( + { + if (item.disabled) return + const previous = hoveredItem() + if (previous !== item) previous?.onMouseLeave?.() + setHoveredItem(item) + item.onMouseEnter?.() + }} + onPointerLeave={() => { + if (item.disabled) return + if (hoveredItem() === item) setHoveredItem(null) + item.onMouseLeave?.() + }} + onSelect={() => { + clearHoveredItem() + void item.onSelect() + }} + > + + {item.label} + + )} + + + + + + ) +} diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index b5c6d5da5..b3ef06b35 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -1,8 +1,8 @@ import { Select } from "@kobalte/core/select" -import { For, Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" -import type { Agent } from "../types/session" +import { isSelectablePrimaryAgent, type Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -34,14 +34,7 @@ export default function AgentSelector(props: AgentSelectorProps) { return allAgents.filter((agent) => !agent.hidden) } - const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent") - - const currentAgent = allAgents.find((a) => a.name === props.currentAgent) - if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { - return [currentAgent, ...filtered] - } - - return filtered + return allAgents.filter(isSelectablePrimaryAgent) }) createEffect(() => { @@ -58,7 +51,6 @@ export default function AgentSelector(props: AgentSelectorProps) { } }) - const handleChange = async (value: Agent | null) => { if (value && value.name !== props.currentAgent) { await props.onAgentChange(value.name) diff --git a/packages/ui/src/components/branded-empty-state.tsx b/packages/ui/src/components/branded-empty-state.tsx new file mode 100644 index 000000000..2a916eaf8 --- /dev/null +++ b/packages/ui/src/components/branded-empty-state.tsx @@ -0,0 +1,31 @@ +import type { Component, JSX } from "solid-js" +import { useI18n } from "../lib/i18n" + +const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href + +interface BrandedEmptyStateProps { + title?: JSX.Element + description: JSX.Element + class?: string + children?: JSX.Element +} + +const BrandedEmptyState: Component = (props) => { + const { t } = useI18n() + + return ( +
+
+
+ +

{t("messageSection.empty.brandTitle")}

+
+ {props.title ?

{props.title}

: null} +

{props.description}

+ {props.children} +
+
+ ) +} + +export default BrandedEmptyState diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx new file mode 100644 index 000000000..48c374b1e --- /dev/null +++ b/packages/ui/src/components/browser-frame.tsx @@ -0,0 +1,381 @@ +import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" +import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" + +export interface BrowserFrameElementTarget { + pagePath: string + tagName: string + text?: string + role?: string + ariaLabel?: string + selector?: string + rect: { x: number; y: number; width: number; height: number } +} + +interface BrowserFrameLabels { + back: string + refresh: string + path: string + go: string + commentMode?: string + viewport?: string + viewportResponsive?: string + viewportDesktop?: string + viewportTablet?: string + viewportTabletLandscape?: string + viewportMobile?: string + viewportMobileLandscape?: string +} + +type BrowserViewportPreset = "responsive" | "desktop" | "tablet" | "tabletLandscape" | "mobile" | "mobileLandscape" + +const VIEWPORT_PRESETS: Record = { + responsive: { width: null, height: null }, + desktop: { width: 1440, height: 900 }, + tablet: { width: 768, height: 1024 }, + tabletLandscape: { width: 1024, height: 768 }, + mobile: { width: 390, height: 844 }, + mobileLandscape: { width: 844, height: 390 }, +} + +const VIEWPORT_OPTIONS = [ + { id: "responsive" as const, icon: Expand, getLabel: (labels: BrowserFrameLabels) => labels.viewportResponsive }, + { id: "desktop" as const, icon: Monitor, getLabel: (labels: BrowserFrameLabels) => labels.viewportDesktop }, + { id: "tablet" as const, icon: Tablet, getLabel: (labels: BrowserFrameLabels) => labels.viewportTablet }, + { id: "tabletLandscape" as const, icon: RotateCw, getLabel: (labels: BrowserFrameLabels) => labels.viewportTabletLandscape }, + { id: "mobile" as const, icon: Smartphone, getLabel: (labels: BrowserFrameLabels) => labels.viewportMobile }, + { id: "mobileLandscape" as const, icon: RotateCw, getLabel: (labels: BrowserFrameLabels) => labels.viewportMobileLandscape }, +] + +interface BrowserFrameProps { + title: string + initialUrl: string + proxyBasePath: string + lockedBaseLabel: string + labels: BrowserFrameLabels + commentMode?: boolean + onToggleCommentMode?: () => void + onCommentTarget?: (target: BrowserFrameElementTarget) => void +} + +function getElementText(element: Element): string | undefined { + const text = (element.textContent ?? "").replace(/\s+/g, " ").trim() + return text ? text.slice(0, 120) : undefined +} + +function getElementSelector(element: Element): string { + const parts: string[] = [] + let current: Element | null = element + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 5) { + const tag = current.tagName.toLowerCase() + const id = current.getAttribute("id") + if (id) { + parts.unshift(`${tag}#${CSS.escape(id)}`) + break + } + + const className = Array.from(current.classList).slice(0, 2).map((item) => `.${CSS.escape(item)}`).join("") + let part = `${tag}${className}` + const parentElement: Element | null = current.parentElement + if (parentElement) { + const siblings = Array.from(parentElement.children as HTMLCollectionOf).filter((child) => child.tagName === current?.tagName) + if (siblings.length > 1) { + part = `${part}:nth-of-type(${siblings.indexOf(current) + 1})` + } + } + parts.unshift(part) + current = parentElement + } + return parts.join(" > ") +} + +export const BrowserFrame: Component = (props) => { + const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) + const [pathInput, setPathInput] = createSignal("/") + const [viewportPreset, setViewportPreset] = createSignal("responsive") + const [viewportMenuOpen, setViewportMenuOpen] = createSignal(false) + const [highlight, setHighlight] = createSignal<{ x: number; y: number; width: number; height: number } | null>(null) + let iframeRef: HTMLIFrameElement | undefined + let frameWrapRef: HTMLDivElement | undefined + let cleanupFrameListeners: (() => void) | null = null + + const canComment = createMemo(() => Boolean(props.onToggleCommentMode && props.onCommentTarget)) + const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()]) + const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") + const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) + + const getEditablePathFromUrl = (url: string): string => { + try { + const parsed = new URL(url, window.location.origin) + const basePath = props.proxyBasePath + let pathname = parsed.pathname + + if (basePath && pathname.startsWith(basePath)) { + pathname = pathname.slice(basePath.length) || "/" + } + + if (!pathname.startsWith("/")) { + pathname = `/${pathname}` + } + + return `${pathname}${parsed.search}${parsed.hash}` + } catch { + return "/" + } + } + + const buildNormalizedTargetUrl = (rawInput: string): string => { + const trimmed = rawInput.trim() + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}` + const parsed = new URL(withLeadingSlash || "/", window.location.origin) + + const safeSegments: string[] = [] + for (const segment of parsed.pathname.split("/")) { + if (!segment || segment === ".") continue + if (segment === "..") { + if (safeSegments.length > 0) safeSegments.pop() + continue + } + safeSegments.push(segment) + } + + const normalizedPath = `/${safeSegments.join("/")}` || "/" + return `${props.proxyBasePath}${normalizedPath}${parsed.search}${parsed.hash}` + } + + const buildElementTarget = (element: Element): BrowserFrameElementTarget => { + const rect = element.getBoundingClientRect() + const pagePath = getEditablePathFromUrl(iframeRef?.contentWindow?.location.href ?? frameSrc()) + return { + pagePath, + tagName: element.tagName.toLowerCase(), + text: getElementText(element), + role: element.getAttribute("role") ?? undefined, + ariaLabel: element.getAttribute("aria-label") ?? undefined, + selector: getElementSelector(element), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + } + } + + const attachCommentListeners = () => { + cleanupFrameListeners?.() + cleanupFrameListeners = null + setHighlight(null) + + if (!props.commentMode || !iframeRef?.contentDocument || !iframeRef.contentWindow || !frameWrapRef) return + const doc = iframeRef.contentDocument + const frameWindow = iframeRef.contentWindow + + const handleMove = (event: MouseEvent) => { + const target = event.target + if (!target || !(target instanceof (frameWindow as any).Element)) return + const element = target as Element + const rect = element.getBoundingClientRect() + const frameRect = iframeRef?.getBoundingClientRect() + const wrapRect = frameWrapRef?.getBoundingClientRect() + if (!frameRect || !wrapRect) return + setHighlight({ + x: frameRect.left - wrapRect.left + rect.x, + y: frameRect.top - wrapRect.top + rect.y, + width: rect.width, + height: rect.height, + }) + } + + const handleLeave = () => setHighlight(null) + + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!target || !(target instanceof (frameWindow as any).Element)) return + event.preventDefault() + event.stopPropagation() + props.onCommentTarget?.(buildElementTarget(target as Element)) + } + + doc.addEventListener("mousemove", handleMove, true) + doc.addEventListener("mouseleave", handleLeave, true) + doc.addEventListener("click", handleClick, true) + cleanupFrameListeners = () => { + doc.removeEventListener("mousemove", handleMove, true) + doc.removeEventListener("mouseleave", handleLeave, true) + doc.removeEventListener("click", handleClick, true) + } + } + + const syncPathInputFromFrame = () => { + try { + const currentHref = iframeRef?.contentWindow?.location.href + if (currentHref) setPathInput(getEditablePathFromUrl(currentHref)) + } catch { + setPathInput(getEditablePathFromUrl(frameSrc())) + } + attachCommentListeners() + } + + createEffect(() => { + setFrameSrc(props.initialUrl) + setPathInput(getEditablePathFromUrl(props.initialUrl)) + }) + + createEffect(() => { + props.commentMode + attachCommentListeners() + }) + + onCleanup(() => cleanupFrameListeners?.()) + + const handleBack = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + try { + iframeRef?.contentWindow?.history.go(-1) + } catch { + // Ignore navigation errors from pages that do not expose history access. + } + } + + const handleRefresh = () => { + try { + iframeRef?.contentWindow?.location.reload() + return + } catch { + // Fall back to resetting the iframe source if the frame cannot be reloaded directly. + } + setFrameSrc("about:blank") + requestAnimationFrame(() => setFrameSrc(props.initialUrl)) + } + + const handleGo = (event?: Event) => { + event?.preventDefault() + const nextUrl = buildNormalizedTargetUrl(pathInput()) + setFrameSrc(nextUrl) + setPathInput(getEditablePathFromUrl(nextUrl)) + } + + return ( +
+
+ + +
+ {props.lockedBaseLabel} +
+
handleGo(event)}> + setPathInput(event.currentTarget.value)} + spellcheck={false} + autocomplete="off" + autocorrect="off" + autocapitalize="off" + aria-label={props.labels.path} + /> + +
+
+ + + + +
+ + + +
+
+
+