Compare commits
187 Commits
v0.1.0
...
v0.2.6-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff356ac5ea | ||
|
|
d68b92ff38 | ||
|
|
940216d98b | ||
|
|
69cd3cf545 | ||
|
|
042a45db0d | ||
|
|
cc45c16d73 | ||
|
|
91fb351a63 | ||
|
|
d9b149a7cb | ||
|
|
222a467a19 | ||
|
|
18513939f7 | ||
|
|
c123714271 | ||
|
|
5c82a2d653 | ||
|
|
435881529e | ||
|
|
700342670c | ||
|
|
2f40f5eedf | ||
|
|
54905c5626 | ||
|
|
1bf1a4761d | ||
|
|
755695a35a | ||
|
|
6a9a442948 | ||
|
|
3db9b0f673 | ||
|
|
4e0e5dcdca | ||
|
|
fad2809299 | ||
|
|
c77bfc2ee7 | ||
|
|
f1fa28dd2c | ||
|
|
91ace25333 | ||
|
|
b54db28fb1 | ||
|
|
f13feb3062 | ||
|
|
4622bdc7ea | ||
|
|
919127b6d9 | ||
|
|
27cd4515cd | ||
|
|
93a5c16cab | ||
|
|
16b76385e2 | ||
|
|
9313b2bd6c | ||
|
|
d25cb09714 | ||
|
|
0d0d1271c3 | ||
|
|
1fd3b2e75c | ||
|
|
bf32fcf136 | ||
|
|
48eb6b8982 | ||
|
|
797fafe854 | ||
|
|
b342660ed0 | ||
|
|
169d5ddeb9 | ||
|
|
38642b60e9 | ||
|
|
01effb8924 | ||
|
|
b434bfd3e9 | ||
|
|
ed769911d6 | ||
|
|
dd6efee900 | ||
|
|
48a16a6702 | ||
|
|
841b9daa1f | ||
|
|
1741e49568 | ||
|
|
8577b3d1e6 | ||
|
|
011533b3c4 | ||
|
|
002efad9ad | ||
|
|
3ce5569b82 | ||
|
|
d7c0c225b9 | ||
|
|
f4de0103a8 | ||
|
|
0a9b7fafed | ||
|
|
073604c9f5 | ||
|
|
4062b43380 | ||
|
|
00bd9f9c1c | ||
|
|
3edb0ac09e | ||
|
|
e9f3c4ee52 | ||
|
|
92420d9e02 | ||
|
|
3688be06ee | ||
|
|
8b2be441fc | ||
|
|
b2493a3a53 | ||
|
|
4eb3dbf492 | ||
|
|
adbe0399b2 | ||
|
|
e2461661f7 | ||
|
|
cc012094b4 | ||
|
|
968a6f3cab | ||
|
|
f0d8634a83 | ||
|
|
9f862d5afc | ||
|
|
08f3d75015 | ||
|
|
d9adab3022 | ||
|
|
9019f7622e | ||
|
|
631b5002e7 | ||
|
|
b1987008c7 | ||
|
|
3338109d51 | ||
|
|
c0616df704 | ||
|
|
ca4030e86e | ||
|
|
0a2d57624c | ||
|
|
dbbed94381 | ||
|
|
a088b948b4 | ||
|
|
87faa32c7c | ||
|
|
873be2d6c1 | ||
|
|
5fafed31d0 | ||
|
|
a763831b83 | ||
|
|
076aa4ff9a | ||
|
|
31cbb9cc53 | ||
|
|
ee6db23b14 | ||
|
|
9fe3dcdc6d | ||
|
|
ea644a7d0f | ||
|
|
2f7ddd57dd | ||
|
|
f2f108c14e | ||
|
|
731885f54a | ||
|
|
3c11a1bfcb | ||
|
|
166dd3f719 | ||
|
|
123da12468 | ||
|
|
2a7cdbae42 | ||
|
|
88952a140a | ||
|
|
b64ea411e6 | ||
|
|
44b2bb1b68 | ||
|
|
efabf83a26 | ||
|
|
ac540a18f2 | ||
|
|
4bad384ca0 | ||
|
|
0eb00901b9 | ||
|
|
459b950ab6 | ||
|
|
d7edd4cf4a | ||
|
|
e0bd5ccc92 | ||
|
|
5e82fc4e5d | ||
|
|
1b2c775348 | ||
|
|
16e9cb21da | ||
|
|
cacfbc24cc | ||
|
|
2052c5566e | ||
|
|
2486af2808 | ||
|
|
881afbba0a | ||
|
|
b6d48bfb69 | ||
|
|
d9596f7b4b | ||
|
|
6467bdfe7c | ||
|
|
4fdd299919 | ||
|
|
2de2d26043 | ||
|
|
70e6052dc8 | ||
|
|
2ff51c1866 | ||
|
|
d6fdef68d9 | ||
|
|
30b075e4ba | ||
|
|
3f46d73a31 | ||
|
|
038cf3c762 | ||
|
|
85c0632719 | ||
|
|
c4c2c92974 | ||
|
|
c5fd5694ee | ||
|
|
bc5423ce14 | ||
|
|
8fab34e356 | ||
|
|
d3ee15dcd7 | ||
|
|
45dca7a7f0 | ||
|
|
885059b0aa | ||
|
|
629d098add | ||
|
|
7e95005d8c | ||
|
|
7aa94e7a88 | ||
|
|
146eae5220 | ||
|
|
defa637dbc | ||
|
|
a43a004e23 | ||
|
|
a3f02befa7 | ||
|
|
40e8c90bab | ||
|
|
f53564bb06 | ||
|
|
719a9c9c74 | ||
|
|
08d81f8bb5 | ||
|
|
89bd32814f | ||
|
|
aa77ca2931 | ||
|
|
b46110937b | ||
|
|
28aa5da16d | ||
|
|
03237f6f79 | ||
|
|
492c6064f9 | ||
|
|
fa8eacde53 | ||
|
|
742c2d2c29 | ||
|
|
eb279cf251 | ||
|
|
6658c0b15a | ||
|
|
12044988d6 | ||
|
|
c4e76aaac4 | ||
|
|
2b6597ad00 | ||
|
|
cce5d1aba8 | ||
|
|
04db4fcf94 | ||
|
|
cb161e57a4 | ||
|
|
b92fbd93a8 | ||
|
|
1a0ccac634 | ||
|
|
cd9d7c2a39 | ||
|
|
941052acc8 | ||
|
|
5f67a01864 | ||
|
|
b80e332021 | ||
|
|
df625e0fe7 | ||
|
|
cd2bd3c636 | ||
|
|
6e7003c57c | ||
|
|
adee1e0383 | ||
|
|
efe7af6f77 | ||
|
|
6fa41d51be | ||
|
|
8431b9f8a2 | ||
|
|
541027c93e | ||
|
|
9f2edbb9db | ||
|
|
eced9b8124 | ||
|
|
68b6793bf3 | ||
|
|
d3b194c306 | ||
|
|
467cbf4b28 | ||
|
|
756f3d68cb | ||
|
|
7354f08abe | ||
|
|
db5bd9984e | ||
|
|
6fdd4947f9 | ||
|
|
b438702092 | ||
|
|
5faa06601a |
519
.github/workflows/build-and-upload.yml
vendored
Normal file
@@ -0,0 +1,519 @@
|
||||
name: Build and Upload Binaries
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to apply to workspace packages"
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "Git tag to upload assets to"
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
description: "Release name (unused here, for context)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Build macOS binaries (Electron)
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Build Windows binaries (Electron)
|
||||
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux binaries (Electron)
|
||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Build macOS bundle (Tauri)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
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"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos-arm64:
|
||||
runs-on: macos-26
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-arm64 --no-save
|
||||
|
||||
- name: Build macOS bundle (Tauri, arm64)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
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"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Build Windows bundle (Tauri)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
$artifactDir = "packages/tauri-app/release-tauri"
|
||||
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
||||
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")
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path "packages/tauri-app/release-tauri") {
|
||||
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
}
|
||||
|
||||
build-tauri-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux bundle (Tauri)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
|
||||
find_one() {
|
||||
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
||||
}
|
||||
|
||||
appimage=$(find_one "*.AppImage")
|
||||
deb=$(find_one "*.deb")
|
||||
rpm=$(find_one "*.rpm")
|
||||
|
||||
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
|
||||
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&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"
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-linux-arm64:
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo dpkg --add-architecture arm64
|
||||
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
|
||||
EOF
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
libglib2.0-dev:arm64 \
|
||||
libwebkit2gtk-4.1-dev:arm64 \
|
||||
libsoup-3.0-dev:arm64 \
|
||||
libayatana-appindicator3-dev:arm64 \
|
||||
librsvg2-dev:arm64
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
||||
|
||||
- name: Build Linux bundle (Tauri arm64)
|
||||
env:
|
||||
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
|
||||
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
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)
|
||||
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
|
||||
if [ -n "$first_artifact" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
|
||||
elif [ -f "$fallback_bin" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
|
||||
else
|
||||
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Tauri release assets (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
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
|
||||
|
||||
- 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
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- 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
|
||||
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
|
||||
65
.github/workflows/dev-release.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Dev Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-dev:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.versions.outputs.version }}
|
||||
tag: ${{ steps.versions.outputs.tag }}
|
||||
release_name: ${{ steps.versions.outputs.release_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Compute dev versions
|
||||
id: versions
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
DEV_VERSION="${BASE_VERSION}-dev"
|
||||
TAG="v${DEV_VERSION}"
|
||||
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-dev
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-dev.outputs.version }}
|
||||
tag: ${{ needs.prepare-dev.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-dev.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs: prepare-dev
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-dev.outputs.version }}
|
||||
dist_tag: dev
|
||||
secrets: inherit
|
||||
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Manual NPM Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.2.0-dev)"
|
||||
required: false
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm >=11.5.1
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_INPUT="${{ inputs.version }}"
|
||||
if [ -z "$VERSION_INPUT" ]; then
|
||||
VERSION_INPUT=$(node -p "require('./package.json').version")
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||
run: |
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
93
.github/workflows/release.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
@@ -63,81 +64,21 @@ jobs:
|
||||
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
||||
fi
|
||||
|
||||
build-macos:
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
|
||||
secrets: inherit
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build macOS binaries
|
||||
run: npm run build:mac
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
|
||||
build-windows:
|
||||
needs: prepare-release
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Windows binaries
|
||||
run: npm run build:win
|
||||
|
||||
- name: Upload release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
|
||||
build-linux:
|
||||
needs: prepare-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Linux binaries
|
||||
run: npm run build:linux
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
publish-server:
|
||||
needs:
|
||||
- prepare-release
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: latest
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Develops Web UI components.
|
||||
mode: all
|
||||
model: zai-coding-plan/glm-4.6
|
||||
---
|
||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||
|
||||
20
AGENTS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# AGENT NOTES
|
||||
|
||||
## Styling Guidelines
|
||||
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
|
||||
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
|
||||
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
|
||||
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
|
||||
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
|
||||
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
|
||||
|
||||
## Coding Principles
|
||||
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
|
||||
- Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts.
|
||||
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
|
||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||
|
||||
## 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.
|
||||
10
BUILD.md
@@ -10,6 +10,12 @@ This guide explains how to build distributable binaries for CodeNomad.
|
||||
|
||||
## Quick Start
|
||||
|
||||
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
|
||||
|
||||
```bash
|
||||
npm run build --workspace @neuralnomads/codenomad-electron-app
|
||||
```
|
||||
|
||||
### Build for Current Platform (macOS default)
|
||||
|
||||
```bash
|
||||
@@ -71,8 +77,8 @@ bun run build:all
|
||||
|
||||
The build script performs these steps:
|
||||
|
||||
1. **Compile TypeScript** → Electron app (main, preload, renderer)
|
||||
2. **Bundle with Vite** → Optimized production build
|
||||
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
|
||||
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
|
||||
3. **Package with electron-builder** → Platform-specific binaries
|
||||
|
||||
## Output
|
||||
|
||||
237
README.md
@@ -1,219 +1,76 @@
|
||||
# CodeNomad
|
||||
|
||||
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
|
||||
## Overview
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
|
||||
CodeNomad provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
|
||||

|
||||
_Manage multiple OpenCode sessions side-by-side._
|
||||
|
||||
**🎯 MVP Focus:** This project prioritizes functionality over performance. Performance optimization is intentionally deferred to post-MVP phases. See [docs/MVP-PRINCIPLES.md](docs/MVP-PRINCIPLES.md) for details.
|
||||
<details>
|
||||
<summary>📸 More Screenshots</summary>
|
||||
|
||||
## Features
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
|
||||
### Core Capabilities
|
||||

|
||||
_Rich media previews for images and assets._
|
||||
|
||||
- **Multi-Instance Management**: Work on multiple projects simultaneously
|
||||
- **Session Persistence**: Resume conversations across app restarts
|
||||
- **Real-time Streaming**: Live message updates via Server-Sent Events
|
||||
- **Tool Execution Visibility**: See bash commands, file edits, and other tool calls
|
||||
- **Agent & Model Switching**: Easily switch between different AI agents and models
|
||||
- **Markdown Rendering**: Beautiful code highlighting and formatting
|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
### Advanced Features (Planned)
|
||||
</details>
|
||||
|
||||
- Virtual scrolling for large conversations
|
||||
- Full-text search across sessions
|
||||
- Workspace management
|
||||
- Custom themes
|
||||
- Plugin system
|
||||
## Getting Started
|
||||
|
||||
## Architecture
|
||||
Choose the way that fits your workflow:
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
|
||||
### High-Level Overview
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
|
||||
```
|
||||
Electron App
|
||||
├── Main Process (Node.js)
|
||||
│ ├── Window management
|
||||
│ ├── OpenCode server spawning
|
||||
│ └── IPC communication
|
||||
├── Renderer Process (SolidJS)
|
||||
│ ├── UI components
|
||||
│ ├── State management (stores)
|
||||
│ └── SDK client communication
|
||||
└── Multiple OpenCode Servers
|
||||
└── One per instance/project folder
|
||||
```
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
## Prerequisites
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
|
||||
- Node.js 18+
|
||||
- Bun package manager
|
||||
- OpenCode CLI installed and in PATH
|
||||
|
||||
## Installation
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run in development mode
|
||||
bun run dev
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
# Build distributable binaries
|
||||
bun run build:mac # macOS (Universal)
|
||||
bun run build:win # Windows (x64)
|
||||
bun run build:linux # Linux (x64)
|
||||
bun run build:all # All platforms
|
||||
|
||||
# See BUILD.md for more build options
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
## Development
|
||||
This command starts the server and opens the web client in your default browser.
|
||||
|
||||
### Project Structure
|
||||
## Highlights
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── docs/ # Documentation
|
||||
├── tasks/ # Task management
|
||||
│ ├── todo/ # Pending tasks
|
||||
│ └── done/ # Completed tasks
|
||||
├── electron/ # Electron main process
|
||||
│ ├── main/ # Main process code
|
||||
│ ├── preload/ # Preload scripts
|
||||
│ └── resources/ # App icons, etc.
|
||||
└── src/ # Renderer (UI) code
|
||||
├── components/ # UI components
|
||||
├── stores/ # State management
|
||||
├── lib/ # Utilities
|
||||
├── hooks/ # SolidJS hooks
|
||||
└── types/ # TypeScript types
|
||||
```
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
|
||||
### Tech Stack
|
||||
## Requirements
|
||||
|
||||
- **Electron** - Desktop wrapper
|
||||
- **SolidJS** - Reactive UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool
|
||||
- **TailwindCSS** - Styling
|
||||
- **Kobalte** - Accessible UI primitives
|
||||
- **OpenCode SDK** - API client
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
|
||||
### Scripts
|
||||
## Architecture & Development
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server with hot reload
|
||||
bun run build # Build for production
|
||||
bun run typecheck # Run TypeScript type checking
|
||||
bun run preview # Preview production build
|
||||
```
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
|
||||
## Usage
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
|
||||
### Starting an Instance
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
|
||||
1. Launch the app
|
||||
2. Click "Select Folder" or press Cmd/Ctrl+N
|
||||
3. Choose a project folder
|
||||
4. Wait for OpenCode server to start
|
||||
5. Select an existing session or create new one
|
||||
|
||||
### Working with Sessions
|
||||
|
||||
- **Switch sessions**: Click session tab at bottom
|
||||
- **Create session**: Click "+" button or Cmd/Ctrl+T
|
||||
- **Change agent**: Use agent dropdown
|
||||
- **Change model**: Use model dropdown
|
||||
|
||||
### Sending Messages
|
||||
|
||||
- Type in the input box at bottom
|
||||
- Press Enter for new line (Cmd+Enter on macOS, Ctrl+Enter on Windows/Linux)
|
||||
- Use `/` for commands
|
||||
- Use `@` to mention files
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) - System design and structure
|
||||
- [User Interface](docs/user-interface.md) - UI specifications
|
||||
- [Technical Implementation](docs/technical-implementation.md) - Implementation details
|
||||
- [Build Roadmap](docs/build-roadmap.md) - Development plan and phases
|
||||
- [Tasks](tasks/README.md) - Task breakdown and tracking
|
||||
|
||||
## Build Phases
|
||||
|
||||
The project is built in phases:
|
||||
|
||||
1. **Phase 1**: Foundation (Tasks 001-005)
|
||||
2. **Phase 2**: Core Chat (Tasks 006-010)
|
||||
3. **Phase 3**: Essential Features (Tasks 011-015)
|
||||
4. **Phase 4**: Multi-Instance (Tasks 016-020)
|
||||
5. **Phase 5**: Advanced Input (Tasks 021-025)
|
||||
6. **Phase 6**: Polish & UX (Tasks 026-030)
|
||||
7. **Phase 7**: System Integration (Tasks 031-035)
|
||||
8. **Phase 8**: Advanced Features (Tasks 036-040)
|
||||
|
||||
See [docs/build-roadmap.md](docs/build-roadmap.md) for detailed phase information.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Read the documentation in `docs/`
|
||||
2. Check `tasks/todo/` for available tasks
|
||||
3. Pick a task and create a feature branch
|
||||
4. Follow the task steps
|
||||
5. Submit PR when complete
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use TypeScript for all code
|
||||
- Follow existing patterns and conventions
|
||||
- Write clear, descriptive commit messages
|
||||
- Add comments for complex logic
|
||||
- Keep components small and focused
|
||||
|
||||
### Testing
|
||||
|
||||
- Test manually at minimum window size (800x600)
|
||||
- Test on multiple platforms (macOS, Windows, Linux)
|
||||
- Verify keyboard navigation works
|
||||
- Check accessibility with screen readers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
- Verify `opencode` is in PATH: `which opencode`
|
||||
- Check folder permissions
|
||||
- Review server logs in Logs tab
|
||||
- Try restarting the instance
|
||||
|
||||
### Connection Issues
|
||||
|
||||
- Check if server is running: `ps aux | grep opencode`
|
||||
- Verify port is correct in instance metadata
|
||||
- Check for firewall blocking localhost
|
||||
- Try killing and restarting server
|
||||
|
||||
### Performance Issues
|
||||
|
||||
- Check number of messages in session
|
||||
- Monitor memory usage in Activity Monitor
|
||||
- Consider enabling virtual scrolling (Phase 8)
|
||||
- Close unused instances
|
||||
|
||||
## License
|
||||
|
||||
[License TBD]
|
||||
|
||||
## Credits
|
||||
|
||||
Built with ❤️ for the OpenCode project.
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
|
||||
@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
│ │ │ State Management (SolidJS Stores) │ │ │
|
||||
│ │ │ - instances[] │ │ │
|
||||
│ │ │ - sessions[] per instance │ │ │
|
||||
│ │ │ - messages[] per session │ │ │
|
||||
│ │ │ - normalized message store per session │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ UI Components │ │ │
|
||||
│ │ │ - InstanceTabs │ │ │
|
||||
│ │ │ - SessionTabs │ │ │
|
||||
│ │ │ - MessageStream │ │ │
|
||||
│ │ │ - MessageStreamV2 │ │ │
|
||||
│ │ │ - PromptInput │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
@@ -104,6 +104,12 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
- Event type routing
|
||||
- Reconnection logic
|
||||
|
||||
**CLI Proxy Paths:**
|
||||
|
||||
- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance.
|
||||
- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces/<id>/instance`), which acts as the base URL for both REST and SSE calls.
|
||||
- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Instance Creation Flow
|
||||
@@ -144,6 +150,7 @@ instances: Map<instanceId, {
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
proxyPath: string // `/workspaces/:id/instance`
|
||||
status: 'starting' | 'ready' | 'error' | 'stopped'
|
||||
client: OpenCodeClient
|
||||
eventSource: EventSource
|
||||
@@ -49,7 +49,7 @@ packages/opencode-client/
|
||||
│ ├── components/
|
||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||
│ │ ├── message-stream.tsx # Messages display
|
||||
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||
│ │ ├── message-item.tsx # Single message
|
||||
│ │ ├── tool-call.tsx # Tool execution display
|
||||
│ │ ├── prompt-input.tsx # Input with attachments
|
||||
@@ -153,16 +153,24 @@ interface Session {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
messages: Message[]
|
||||
status: SessionStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
version: string
|
||||
time: { created: number; updated: number }
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Message content lives in the normalized message-v2 store
|
||||
// keyed by instanceId/sessionId/messageId
|
||||
|
||||
type SessionStatus =
|
||||
| "idle" // No activity
|
||||
| "streaming" // Assistant responding
|
||||
| "error" // Error occurred
|
||||
|
||||
```
|
||||
|
||||
### UI Store
|
||||
BIN
docs/screenshots/browser-support.png
Normal file
|
After Width: | Height: | Size: 845 KiB |
BIN
docs/screenshots/command-palette.png
Normal file
|
After Width: | Height: | Size: 835 KiB |
BIN
docs/screenshots/image-previews.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/screenshots/newSession.png
Normal file
|
After Width: | Height: | Size: 966 KiB |
@@ -1,243 +0,0 @@
|
||||
import { ipcMain, BrowserWindow, dialog } from "electron"
|
||||
import { processManager } from "./process-manager"
|
||||
import { randomBytes } from "crypto"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import ignore from "ignore"
|
||||
|
||||
interface Instance {
|
||||
id: string
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
}
|
||||
|
||||
const instances = new Map<string, Instance>()
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(16).toString("hex")
|
||||
}
|
||||
|
||||
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(binaryPath, ["-v"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error("Version check timed out"))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
processManager.setMainWindow(mainWindow)
|
||||
|
||||
ipcMain.handle("dialog:selectFolder", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select Project Folder",
|
||||
properties: ["openDirectory"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"instance:create",
|
||||
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
}
|
||||
|
||||
instances.set(id, instance)
|
||||
|
||||
try {
|
||||
const {
|
||||
pid,
|
||||
port,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
|
||||
|
||||
instance.port = port
|
||||
instance.pid = pid
|
||||
instance.status = "ready"
|
||||
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
|
||||
|
||||
const meta = processManager.getAllProcesses().get(pid)
|
||||
if (meta) {
|
||||
meta.childProcess.on("exit", (code, signal) => {
|
||||
instance.status = "stopped"
|
||||
mainWindow.webContents.send("instance:stopped", { id })
|
||||
})
|
||||
}
|
||||
|
||||
return { id, port, pid, binaryPath: actualBinaryPath }
|
||||
} catch (error) {
|
||||
instance.status = "error"
|
||||
instance.error = error instanceof Error ? error.message : String(error)
|
||||
|
||||
mainWindow.webContents.send("instance:error", {
|
||||
id,
|
||||
error: instance.error,
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("instance:stop", async (event, pid: number) => {
|
||||
await processManager.kill(pid)
|
||||
|
||||
for (const [id, instance] of instances.entries()) {
|
||||
if (instance.pid === pid) {
|
||||
instance.status = "stopped"
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:status", async (event, pid: number) => {
|
||||
return processManager.getStatus(pid)
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:list", async () => {
|
||||
return Array.from(instances.values())
|
||||
})
|
||||
|
||||
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
|
||||
const ig = ignore()
|
||||
ig.add([".git", "node_modules"])
|
||||
|
||||
const gitignorePath = path.join(workspaceFolder, ".gitignore")
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
const content = fs.readFileSync(gitignorePath, "utf-8")
|
||||
ig.add(content)
|
||||
}
|
||||
|
||||
function scanDir(dirPath: string, baseDir: string): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
const relativePath = path.relative(baseDir, fullPath)
|
||||
|
||||
if (ig.ignores(relativePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const dirWithSlash = relativePath + "/"
|
||||
if (!ig.ignores(dirWithSlash)) {
|
||||
results.push(dirWithSlash)
|
||||
const subFiles = scanDir(fullPath, baseDir)
|
||||
results.push(...subFiles)
|
||||
}
|
||||
} else {
|
||||
results.push(relativePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error scanning ${dirPath}:`, error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
return scanDir(workspaceFolder, workspaceFolder)
|
||||
})
|
||||
|
||||
// OpenCode binary operations
|
||||
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select OpenCode Binary",
|
||||
filters: [
|
||||
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
|
||||
try {
|
||||
// Special handling for system PATH binary
|
||||
const isSystemPath = binaryPath === "opencode"
|
||||
|
||||
if (!isSystemPath) {
|
||||
// Check if file exists and is executable for custom paths
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return { valid: false, error: "File does not exist" }
|
||||
}
|
||||
|
||||
const stats = fs.statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
return { valid: false, error: "Path is not a file" }
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get version once via -v flag
|
||||
try {
|
||||
const version = await runBinaryVersion(binaryPath)
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
|
||||
import { join } from "path"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupInstanceIPC } from "./ipc"
|
||||
import { setupStorageIPC } from "./storage"
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
// Setup IPC handlers before creating windows
|
||||
setupStorageIPC()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "electron/resources/icon.png")
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true //nativeTheme.shouldUseDarkColors
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
// Disable macOS spell server to avoid input lag
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.loadURL("http://localhost:3000")
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupInstanceIPC(mainWindow)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
|
||||
if (app.dock) {
|
||||
const dockIcon = nativeImage.createFromPath(getIconPath())
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
@@ -1,281 +0,0 @@
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
binaryPath: string
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
logs: string[]
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
class ProcessManager {
|
||||
private processes = new Map<number, ProcessMeta>()
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
|
||||
setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window
|
||||
}
|
||||
|
||||
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
|
||||
const upperMessage = message.toUpperCase()
|
||||
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
|
||||
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
|
||||
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
|
||||
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
|
||||
return "info"
|
||||
}
|
||||
|
||||
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
|
||||
if (this.mainWindow && message.trim()) {
|
||||
const parsedLevel = this.parseLogLevel(message)
|
||||
this.mainWindow.webContents.send("instance:log", {
|
||||
id: instanceId,
|
||||
entry: {
|
||||
timestamp: Date.now(),
|
||||
level: parsedLevel,
|
||||
message: message.trim(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async spawn(
|
||||
folder: string,
|
||||
instanceId: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
const actualBinaryPath =
|
||||
binaryPath && binaryPath !== "opencode" ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
|
||||
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Starting OpenCode server for ${folder} using ${binaryPath || "opencode"} (${actualBinaryPath})...`,
|
||||
)
|
||||
|
||||
// Merge environment variables with process environment
|
||||
const env = { ...process.env }
|
||||
if (environmentVariables) {
|
||||
Object.assign(env, environmentVariables)
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
|
||||
)
|
||||
|
||||
// Log each environment variable
|
||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
||||
this.sendLog(instanceId, "info", ` ${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
|
||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
||||
}, 10000)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "info", line)
|
||||
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
|
||||
if (portMatch && !portFound) {
|
||||
portFound = true
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
clearTimeout(timeout)
|
||||
|
||||
const meta: ProcessMeta = {
|
||||
pid: child.pid!,
|
||||
port,
|
||||
folder,
|
||||
startTime: Date.now(),
|
||||
childProcess: child,
|
||||
logs: [line],
|
||||
instanceId,
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
|
||||
}
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "error", line)
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
if (error.message.includes("ENOENT")) {
|
||||
reject(new Error("opencode binary not found in PATH"))
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
this.processes.delete(child.pid!)
|
||||
|
||||
if (!portFound) {
|
||||
const errorMsg = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async kill(pid: number): Promise<void> {
|
||||
const meta = this.processes.get(pid)
|
||||
if (!meta) {
|
||||
// Treat unknown processes as already stopped so tabs close cleanly
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = meta.childProcess
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 2000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.processes.delete(pid)
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(pid: number): "running" | "stopped" | "unknown" {
|
||||
if (!this.processes.has(pid)) {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return "running"
|
||||
} catch {
|
||||
return "stopped"
|
||||
}
|
||||
}
|
||||
|
||||
getAllProcesses(): Map<number, ProcessMeta> {
|
||||
return new Map(this.processes)
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
|
||||
await Promise.all(killPromises)
|
||||
}
|
||||
|
||||
private validateFolder(folder: string): void {
|
||||
if (!existsSync(folder)) {
|
||||
throw new Error(`Folder does not exist: ${folder}`)
|
||||
}
|
||||
|
||||
const stats = statSync(folder)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${folder}`)
|
||||
}
|
||||
}
|
||||
|
||||
private validateOpenCodeBinary(): string {
|
||||
const command = process.platform === "win32" ? "where opencode" : "which opencode"
|
||||
try {
|
||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
||||
const paths = output.trim().split("\n")
|
||||
return paths[0].trim()
|
||||
} catch {
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateCustomBinary(binaryPath: string): string {
|
||||
if (!existsSync(binaryPath)) {
|
||||
throw new Error(`OpenCode binary not found: ${binaryPath}`)
|
||||
}
|
||||
|
||||
const stats = statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${binaryPath}`)
|
||||
}
|
||||
|
||||
// Check if executable (on Unix systems)
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
|
||||
} catch {
|
||||
throw new Error(`Binary is not executable: ${binaryPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return binaryPath
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await processManager.cleanup()
|
||||
app.exit(0)
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
|
||||
export interface ElectronAPI {
|
||||
selectFolder: () => Promise<string | null>
|
||||
createInstance: (
|
||||
id: string,
|
||||
folder: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
|
||||
stopInstance: (pid: number) => Promise<void>
|
||||
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
|
||||
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
|
||||
onInstanceStopped: (callback: (data: { id: string }) => void) => void
|
||||
onInstanceLog: (
|
||||
callback: (data: {
|
||||
id: string
|
||||
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
|
||||
}) => void,
|
||||
) => void
|
||||
onNewInstance: (callback: () => void) => void
|
||||
scanDirectory: (workspaceFolder: string) => Promise<string[]>
|
||||
// OpenCode binary operations
|
||||
selectOpenCodeBinary: () => Promise<string | null>
|
||||
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
|
||||
// Storage operations
|
||||
getConfigPath: () => Promise<string>
|
||||
getInstancesDir: () => Promise<string>
|
||||
readConfigFile: () => Promise<string>
|
||||
writeConfigFile: (content: string) => Promise<void>
|
||||
readInstanceFile: (instanceId: string) => Promise<string>
|
||||
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
|
||||
deleteInstanceFile: (instanceId: string) => Promise<void>
|
||||
onConfigChanged: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
|
||||
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
|
||||
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
|
||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||
onInstanceStarted: (callback) => {
|
||||
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceError: (callback) => {
|
||||
ipcRenderer.on("instance:error", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceStopped: (callback) => {
|
||||
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceLog: (callback) => {
|
||||
ipcRenderer.on("instance:log", (_, data) => callback(data))
|
||||
},
|
||||
onNewInstance: (callback) => {
|
||||
ipcRenderer.on("menu:newInstance", () => callback())
|
||||
},
|
||||
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
|
||||
// OpenCode binary operations
|
||||
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
|
||||
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
|
||||
// Storage operations
|
||||
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
|
||||
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
|
||||
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
|
||||
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
|
||||
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
|
||||
writeInstanceFile: (filename: string, content: string) =>
|
||||
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
|
||||
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
||||
onConfigChanged: (callback: () => void) => {
|
||||
ipcRenderer.on("storage:configChanged", () => callback())
|
||||
return () => ipcRenderer.removeAllListeners("storage:configChanged")
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
2825
package-lock.json
generated
175
package.json
@@ -1,158 +1,27 @@
|
||||
{
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "0.1.0",
|
||||
"description": "CodeNomad desktop client - multi-instance, multi-session AI coding interface",
|
||||
"author": "OpenCode Team",
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development electron .",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
"build:mac-x64": "node scripts/build.js mac-x64",
|
||||
"build:mac-arm64": "node scripts/build.js mac-arm64",
|
||||
"build:win": "node scripts/build.js win",
|
||||
"build:win-arm64": "node scripts/build.js win-arm64",
|
||||
"build:linux": "node scripts/build.js linux",
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
|
||||
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
|
||||
"build:ui": "npm run build --workspace @codenomad/ui",
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
},
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "0.15.13",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"ignore": "7.0.5",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "^1.0.9",
|
||||
"autoprefixer": "10.4.21",
|
||||
"electron": "39.0.0",
|
||||
"png2icons": "^2.0.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64",
|
||||
"universal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64",
|
||||
"universal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/electron-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
40
packages/electron-app/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# CodeNomad App
|
||||
|
||||
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
|
||||
|
||||
## Overview
|
||||
|
||||
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
|
||||
- Native window management
|
||||
- Global keyboard shortcuts
|
||||
- Application menu integration
|
||||
|
||||
## Development
|
||||
|
||||
To run the Electron app in development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start the renderer (UI) and the main process with hot reloading.
|
||||
|
||||
## Building
|
||||
|
||||
To build the application for your current platform:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
To build for specific platforms (requires appropriate build tools):
|
||||
|
||||
- **macOS**: `npm run build:mac`
|
||||
- **Windows**: `npm run build:win`
|
||||
- **Linux**: `npm run build:linux`
|
||||
|
||||
## Structure
|
||||
|
||||
- `electron/main`: Main process code (window creation, IPC).
|
||||
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
|
||||
- `electron/resources`: Static assets like icons.
|
||||
@@ -2,6 +2,12 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
|
||||
const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
@@ -20,7 +26,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist/preload",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/preload/index.ts"),
|
||||
entry: resolve(__dirname, "electron/preload/index.cjs"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
@@ -33,21 +39,27 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: "./src/renderer",
|
||||
root: uiRendererRoot,
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: "./postcss.config.js",
|
||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
"@": uiSrc,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: "dist/renderer",
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: uiRendererEntry,
|
||||
loading: uiRendererLoadingEntry,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
59
packages/electron-app/electron/main/ipc.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: Array<{ name?: string; extensions: string[] }>
|
||||
}
|
||||
|
||||
interface DialogOpenResult {
|
||||
canceled: boolean
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:status", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:ready", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("error", (error: Error) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||
|
||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||
const properties: OpenDialogOptions["properties"] =
|
||||
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||
|
||||
const filters = request.filters?.map((filter) => ({
|
||||
name: filter.name ?? "Files",
|
||||
extensions: filter.extensions,
|
||||
}))
|
||||
|
||||
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
|
||||
const dialogOptions: OpenDialogOptions = {
|
||||
title: request.title,
|
||||
defaultPath: request.defaultPath,
|
||||
properties,
|
||||
filters,
|
||||
}
|
||||
const result = windowTarget
|
||||
? await dialog.showOpenDialog(windowTarget, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
}
|
||||
329
packages/electron-app/electron/main/main.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = dirname(mainFilename)
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
let pendingCliUrl: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(mainDirname, "../resources/icon.png")
|
||||
}
|
||||
|
||||
type LoadingTarget =
|
||||
| { type: "url"; source: string }
|
||||
| { type: "file"; source: string }
|
||||
|
||||
function resolveDevLoadingUrl(): string | null {
|
||||
if (app.isPackaged) {
|
||||
return null
|
||||
}
|
||||
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
|
||||
if (!devBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||
return new URL("loading.html", normalized).toString()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to construct dev loading URL", devBase, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoadingTarget(): LoadingTarget {
|
||||
const devUrl = resolveDevLoadingUrl()
|
||||
if (devUrl) {
|
||||
return { type: "url", source: devUrl }
|
||||
}
|
||||
const filePath = resolveLoadingFilePath()
|
||||
return { type: "file", source: filePath }
|
||||
}
|
||||
|
||||
function resolveLoadingFilePath() {
|
||||
const candidates = [
|
||||
join(app.getAppPath(), "dist/renderer/loading.html"),
|
||||
join(process.resourcesPath, "dist/renderer/loading.html"),
|
||||
join(mainDirname, "../dist/renderer/loading.html"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||
}
|
||||
|
||||
function loadLoadingScreen(window: BrowserWindow) {
|
||||
const target = resolveLoadingTarget()
|
||||
const loader =
|
||||
target.type === "url"
|
||||
? window.loadURL(target.source)
|
||||
: window.loadFile(target.source)
|
||||
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
return cachedPreloadPath
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(process.resourcesPath, "preload/index.js"),
|
||||
join(mainDirname, "../preload/index.js"),
|
||||
join(mainDirname, "../preload/index.cjs"),
|
||||
join(mainDirname, "../../preload/index.cjs"),
|
||||
join(mainDirname, "../../electron/preload/index.cjs"),
|
||||
join(app.getAppPath(), "preload/index.cjs"),
|
||||
join(app.getAppPath(), "electron/preload/index.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedPreloadPath = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(mainDirname, "../preload/index.js")
|
||||
}
|
||||
|
||||
function destroyPreloadingView(target?: BrowserView | null) {
|
||||
const view = target ?? preloadingView
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = view.webContents as any
|
||||
contents?.destroy?.()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to destroy preloading view", error)
|
||||
}
|
||||
|
||||
if (!target || view === preloadingView) {
|
||||
preloadingView = null
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (showingLoadingScreen && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
destroyPreloadingView()
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
if (currentCliUrl === url && !showingLoadingScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingCliUrl = url
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!showingLoadingScreen) {
|
||||
showLoadingScreen(true)
|
||||
}
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
preloadingView = view
|
||||
|
||||
view.webContents.once("did-finish-load", () => {
|
||||
if (preloadingView !== view) {
|
||||
destroyPreloadingView(view)
|
||||
return
|
||||
}
|
||||
finalizeCliSwap(url)
|
||||
})
|
||||
|
||||
view.webContents.loadURL(url).catch((error) => {
|
||||
console.error("[cli] failed to preload CLI view:", error)
|
||||
if (preloadingView === view) {
|
||||
destroyPreloadingView(view)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeCliSwap(url: string) {
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||
await cliManager.start({ dev: devMode })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error("[cli] start failed:", message)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliManager.on("ready", (status) => {
|
||||
if (!status.url) {
|
||||
return
|
||||
}
|
||||
startCliPreload(status.url)
|
||||
})
|
||||
|
||||
cliManager.on("status", (status) => {
|
||||
if (status.state !== "ready") {
|
||||
showLoadingScreen()
|
||||
}
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
|
||||
if (app.dock) {
|
||||
const dockIcon = nativeImage.createFromPath(getIconPath())
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await cliManager.stop().catch(() => {})
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
326
packages/electron-app/electron/main/process-manager.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
|
||||
export interface CliStatus {
|
||||
state: CliState
|
||||
pid?: number
|
||||
port?: number
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CliLogEntry {
|
||||
stream: "stdout" | "stderr"
|
||||
message: string
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
dev: boolean
|
||||
}
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||
on(event: "error", listener: (error: Error) => void): this
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
await this.stop()
|
||||
}
|
||||
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const args = this.buildCliArgs(options)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
console.error("[cli] spawn failed: no pid")
|
||||
}
|
||||
|
||||
this.child = child
|
||||
this.updateStatus({ pid: child.pid ?? undefined })
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stdout")
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
|
||||
return new Promise<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.handleTimeout()
|
||||
reject(new Error("CLI startup timeout"))
|
||||
}, 15000)
|
||||
|
||||
this.once("ready", (status) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(status)
|
||||
})
|
||||
|
||||
this.once("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 4000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(): CliStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
this.emit("error", new Error("CLI did not start in time"))
|
||||
}
|
||||
|
||||
private handleStream(chunk: string, stream: "stdout" | "stderr") {
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer += chunk
|
||||
this.processBuffer("stdout")
|
||||
} else {
|
||||
this.stderrBuffer += chunk
|
||||
this.processBuffer("stderr")
|
||||
}
|
||||
}
|
||||
|
||||
private processBuffer(stream: "stdout" | "stderr") {
|
||||
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
|
||||
const lines = buffer.split("\n")
|
||||
const trailing = lines.pop() ?? ""
|
||||
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer = trailing
|
||||
} else {
|
||||
this.stderrBuffer = trailing
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
console.info(`[cli][${stream}] ${line}`)
|
||||
this.emit("log", { stream, message: line })
|
||||
|
||||
const port = this.extractPort(line)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
this.updateStatus({ state: "ready", port, url })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPort(line: string): number | null {
|
||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||
if (readyMatch) {
|
||||
return parseInt(readyMatch[1], 10)
|
||||
}
|
||||
|
||||
if (line.toLowerCase().includes("http server listening")) {
|
||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||
if (httpMatch) {
|
||||
return parseInt(httpMatch[1], 10)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (typeof parsed.port === "number") {
|
||||
return parsed.port
|
||||
}
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
this.status = { ...this.status, ...patch }
|
||||
this.emit("status", this.status)
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions): string[] {
|
||||
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||
const parts = [JSON.stringify(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
}
|
||||
parts.push(JSON.stringify(cliEntry.entry))
|
||||
args.forEach((arg) => parts.push(JSON.stringify(arg)))
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
return { command: process.execPath, args: [cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||
if (options.dev) {
|
||||
const tsxPath = this.resolveTsx()
|
||||
if (!tsxPath) {
|
||||
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||
}
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
const candidates: Array<string | (() => string)> = [
|
||||
() => nodeRequire.resolve("tsx/cli"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||
if (resolved && existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDevEntry(): string {
|
||||
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from "path"
|
||||
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "opencode-client")
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
|
||||
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
|
||||
|
||||
@@ -59,7 +59,7 @@ export function setupStorageIPC() {
|
||||
return await readConfigWithCache()
|
||||
} catch (error) {
|
||||
// Return empty config if file doesn't exist
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
139
packages/electron-app/electron/main/user-shell.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { spawn, spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
interface ShellCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
function getDefaultShellPath(): string {
|
||||
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
|
||||
return process.env.SHELL
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath)
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string): string[] {
|
||||
const shellName = path.basename(shellPath)
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c"]
|
||||
}
|
||||
return ["-l", "-c"]
|
||||
}
|
||||
|
||||
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const cleaned = { ...env }
|
||||
delete cleaned.npm_config_prefix
|
||||
delete cleaned.NPM_CONFIG_PREFIX
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function supportsUserShell(): boolean {
|
||||
return !isWindows
|
||||
}
|
||||
|
||||
export function buildUserShellCommand(userCommand: string): ShellCommand {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const shellPath = getDefaultShellPath()
|
||||
const script = wrapCommandForShell(userCommand, shellPath)
|
||||
const args = buildShellArgs(shellPath)
|
||||
|
||||
return {
|
||||
command: shellPath,
|
||||
args: [...args, script],
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserShellEnv(): NodeJS.ProcessEnv {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
return sanitizeShellEnv(process.env)
|
||||
}
|
||||
|
||||
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
|
||||
if (!supportsUserShell()) {
|
||||
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function runUserShellCommandSync(userCommand: string): string {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
const result = spawnSync(command, args, { encoding: "utf-8", env })
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").toString().trim()
|
||||
throw new Error(stderr || "Shell command failed")
|
||||
}
|
||||
|
||||
return (result.stdout || "").toString().trim()
|
||||
}
|
||||
16
packages/electron-app/electron/preload/index.cjs
Normal file
@@ -0,0 +1,16 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
},
|
||||
onCliError: (callback) => {
|
||||
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||
},
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.node.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
137
packages/electron-app/package.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.6",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
"build:mac-x64": "node scripts/build.js mac-x64",
|
||||
"build:mac-arm64": "node scripts/build.js mac-arm64",
|
||||
"build:win": "node scripts/build.js win",
|
||||
"build:win-arm64": "node scripts/build.js win-arm64",
|
||||
"build:linux": "node scripts/build.js linux",
|
||||
"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",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
"png2icons": "^2.0.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources",
|
||||
"to": "",
|
||||
"filter": [
|
||||
"!icon.icns",
|
||||
"!icon.ico"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -7,15 +7,17 @@ import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64", "--universal"],
|
||||
description: "macOS (Intel, Apple Silicon, Universal)",
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
description: "macOS (Intel & Apple Silicon)",
|
||||
},
|
||||
"mac-x64": {
|
||||
args: ["--mac", "--x64"],
|
||||
@@ -41,6 +43,10 @@ const platforms = {
|
||||
args: ["--linux", "--arm64"],
|
||||
description: "Linux (ARM64)",
|
||||
},
|
||||
"linux-rpm": {
|
||||
args: ["--linux", "rpm", "--x64", "--arm64"],
|
||||
description: "Linux RPM packages (x64 & ARM64)",
|
||||
},
|
||||
all: {
|
||||
args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
|
||||
description: "All platforms (macOS, Windows, Linux)",
|
||||
@@ -52,6 +58,7 @@ function run(command, args, options = {}) {
|
||||
const spawnOptions = {
|
||||
cwd: appDir,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
||||
}
|
||||
@@ -88,16 +95,22 @@ async function build(platform) {
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
console.log("📦 Step 1/2: Building Electron app...\n")
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
console.log("\n📦 Step 2/2: Packaging binaries...\n")
|
||||
console.log("\n📦 Step 3/3: Packaging binaries...\n")
|
||||
const distPath = join(appDir, "dist")
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error("dist/ directory not found. Build failed.")
|
||||
}
|
||||
|
||||
await run(npxCmd, ["electron-builder", ...config.args])
|
||||
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)
|
||||
1
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
public/
|
||||
5
packages/server/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
scripts/
|
||||
src/
|
||||
tsconfig.json
|
||||
*.tsbuildinfo
|
||||
58
packages/server/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CodeNomad Server
|
||||
|
||||
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
|
||||
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
### Install Globally
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Common Flags
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
1333
packages/server/package-lock.json
generated
Normal file
42
packages/server/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.6",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
21
packages/server/scripts/copy-ui-dist.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
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 uiDistDir = path.resolve(cliRoot, "../ui/src/renderer/dist")
|
||||
const targetDir = path.resolve(cliRoot, "public")
|
||||
|
||||
if (!existsSync(uiDistDir)) {
|
||||
console.error(`[copy-ui-dist] Expected UI build artifacts at ${uiDistDir}. Run the UI build before bundling the CLI.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(uiDistDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-ui-dist] Copied UI bundle from ${uiDistDir} -> ${targetDir}`)
|
||||
200
packages/server/src/api-types.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
* These types are consumed by both the CLI implementation and any UI clients.
|
||||
*/
|
||||
|
||||
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string
|
||||
/** Absolute path on the server host. */
|
||||
path: string
|
||||
name?: string
|
||||
status: WorkspaceStatus
|
||||
/** PID/port are populated when the workspace is running. */
|
||||
pid?: number
|
||||
port?: number
|
||||
/** Canonical proxy path the CLI exposes for this instance. */
|
||||
proxyPath: string
|
||||
/** Identifier of the binary resolved from config. */
|
||||
binaryId: string
|
||||
binaryLabel: string
|
||||
binaryVersion?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** Present when `status` is "error". */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateRequest {
|
||||
path: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateResponse = WorkspaceDescriptor
|
||||
export type WorkspaceListResponse = WorkspaceDescriptor[]
|
||||
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||
|
||||
export interface WorkspaceDeleteResponse {
|
||||
id: string
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
workspaceId: string
|
||||
timestamp: string
|
||||
level: LogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface FileSystemEntry {
|
||||
name: string
|
||||
/** Path relative to the CLI server root ("." represents the root itself). */
|
||||
path: string
|
||||
/** Absolute path when available (unrestricted listings). */
|
||||
absolutePath?: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
/** ISO timestamp of last modification when available. */
|
||||
modifiedAt?: string
|
||||
}
|
||||
|
||||
export type FileSystemScope = "restricted" | "unrestricted"
|
||||
export type FileSystemPathKind = "relative" | "absolute" | "drives"
|
||||
|
||||
export interface FileSystemListingMetadata {
|
||||
scope: FileSystemScope
|
||||
/** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */
|
||||
currentPath: string
|
||||
/** Optional parent path if navigation upward is allowed. */
|
||||
parentPath?: string
|
||||
/** Absolute path representing the root or origin point for this listing. */
|
||||
rootPath: string
|
||||
/** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */
|
||||
homePath: string
|
||||
/** Human-friendly label for the current path. */
|
||||
displayPath: string
|
||||
/** Indicates whether entry paths are relative, absolute, or represent drive roots. */
|
||||
pathKind: FileSystemPathKind
|
||||
}
|
||||
|
||||
export interface FileSystemListResponse {
|
||||
entries: FileSystemEntry[]
|
||||
metadata: FileSystemListingMetadata
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
workspaceId: string
|
||||
relativePath: string
|
||||
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
agentModelSelections: AgentModelSelection
|
||||
}
|
||||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
|
||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||
isDefault: boolean
|
||||
lastValidatedAt?: string
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type AppConfig = ConfigFile
|
||||
export type AppConfigResponse = AppConfig
|
||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
}
|
||||
|
||||
export interface BinaryCreateRequest {
|
||||
path: string
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryUpdateRequest {
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryValidationResult {
|
||||
valid: boolean
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
export type {
|
||||
Preferences,
|
||||
ModelPreference,
|
||||
AgentModelSelections,
|
||||
RecentFolder,
|
||||
OpenCodeBinary,
|
||||
}
|
||||
29
packages/server/src/bin.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliEntry = path.join(__dirname, "index.js")
|
||||
const loaderFileUrl = pathToFileURL(path.join(__dirname, "loader.js")).href
|
||||
const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("${encodeURI(loaderFileUrl)}", pathToFileURL("./"));`
|
||||
const loaderArg = `data:text/javascript,${registerScript}`
|
||||
|
||||
const child = spawn(process.execPath, ["--import", loaderArg, cliEntry, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("Failed to launch CLI runtime", error)
|
||||
process.exit(1)
|
||||
})
|
||||
156
packages/server/src/config/binaries.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
BinaryCreateRequest,
|
||||
BinaryRecord,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
} from "../api-types"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFile } from "./schema"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export class BinaryRegistry {
|
||||
constructor(
|
||||
private readonly configStore: ConfigStore,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
list(): BinaryRecord[] {
|
||||
return this.mapRecords()
|
||||
}
|
||||
|
||||
resolveDefault(): BinaryRecord {
|
||||
const binaries = this.mapRecords()
|
||||
if (binaries.length === 0) {
|
||||
this.logger.warn("No configured binaries found, falling back to opencode")
|
||||
return this.buildFallbackRecord("opencode")
|
||||
}
|
||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||
}
|
||||
|
||||
create(request: BinaryCreateRequest): BinaryRecord {
|
||||
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
||||
const entry = {
|
||||
path: request.path,
|
||||
version: undefined,
|
||||
lastUsed: Date.now(),
|
||||
label: request.label,
|
||||
}
|
||||
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
||||
|
||||
if (request.makeDefault) {
|
||||
nextConfig.preferences.lastUsedBinary = request.path
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(request.path)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||
)
|
||||
|
||||
if (updates.makeDefault) {
|
||||
nextConfig.preferences.lastUsedBinary = id
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(id)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||
nextConfig.opencodeBinaries = remaining
|
||||
|
||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
this.emitChange()
|
||||
}
|
||||
|
||||
validatePath(path: string): BinaryValidationResult {
|
||||
this.logger.debug({ path }, "Validating OpenCode binary path")
|
||||
return this.validateRecord({
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: false,
|
||||
})
|
||||
}
|
||||
|
||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
||||
}
|
||||
|
||||
private mapRecords(): BinaryRecord[] {
|
||||
|
||||
const config = this.configStore.get()
|
||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||
id: binary.path,
|
||||
path: binary.path,
|
||||
label: binary.label ?? this.prettyLabel(binary.path),
|
||||
version: binary.version,
|
||||
isDefault: false,
|
||||
}))
|
||||
|
||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
||||
|
||||
const annotated = configuredBinaries.map((binary) => ({
|
||||
...binary,
|
||||
isDefault: binary.path === defaultPath,
|
||||
}))
|
||||
|
||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
||||
}
|
||||
|
||||
return annotated
|
||||
}
|
||||
|
||||
private getById(id: string): BinaryRecord {
|
||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
this.logger.debug("Emitting binaries changed event")
|
||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||
}
|
||||
|
||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||
// TODO: call actual binary -v check.
|
||||
return { valid: true, version: record.version }
|
||||
}
|
||||
|
||||
private buildFallbackRecord(path: string): BinaryRecord {
|
||||
return {
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
private prettyLabel(path: string) {
|
||||
const parts = path.split(/[\\/]/)
|
||||
const last = parts[parts.length - 1] || path
|
||||
return last || path
|
||||
}
|
||||
}
|
||||
61
packages/server/src/config/schema.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const ModelPreferenceSchema = z.object({
|
||||
providerId: z.string(),
|
||||
modelId: z.string(),
|
||||
})
|
||||
|
||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
lastAccessed: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
const OpenCodeBinarySchema = z.object({
|
||||
path: z.string(),
|
||||
version: z.string().optional(),
|
||||
lastUsed: z.number().nonnegative(),
|
||||
label: z.string().optional(),
|
||||
})
|
||||
|
||||
const ConfigFileSchema = z.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
|
||||
export {
|
||||
ModelPreferenceSchema,
|
||||
AgentModelSelectionSchema,
|
||||
AgentModelSelectionsSchema,
|
||||
PreferencesSchema,
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
DEFAULT_CONFIG,
|
||||
}
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
|
||||
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
|
||||
export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
77
packages/server/src/config/store.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly configPath: string,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
load(): ConfigFile {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
const content = fs.readFileSync(resolved, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
this.cache = ConfigFileSchema.parse(parsed)
|
||||
this.logger.debug({ resolved }, "Loaded existing config file")
|
||||
} else {
|
||||
this.cache = DEFAULT_CONFIG
|
||||
this.logger.debug({ resolved }, "No config file found, using defaults")
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
||||
this.cache = DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
get(): ConfigFile {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
replace(config: ConfigFile) {
|
||||
const validated = ConfigFileSchema.parse(config)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
private commit(next: ConfigFile) {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
this.logger.info("Config updated")
|
||||
this.logger.debug({ config: this.cache }, "Config payload")
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||
this.logger.debug({ resolved }, "Persisted config file")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist config")
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
}
|
||||
42
packages/server/src/events/bus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { WorkspaceEventPayload } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export class EventBus extends EventEmitter {
|
||||
constructor(private readonly logger?: Logger) {
|
||||
super()
|
||||
}
|
||||
|
||||
publish(event: WorkspaceEventPayload): boolean {
|
||||
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||
this.logger?.debug({ event }, "Publishing workspace event")
|
||||
}
|
||||
return super.emit(event.type, event)
|
||||
}
|
||||
|
||||
onEvent(listener: (event: WorkspaceEventPayload) => void) {
|
||||
const handler = (event: WorkspaceEventPayload) => listener(event)
|
||||
this.on("workspace.created", handler)
|
||||
this.on("workspace.started", handler)
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it } from "node:test"
|
||||
import type { FileSystemEntry } from "../../api-types"
|
||||
import {
|
||||
clearWorkspaceSearchCache,
|
||||
getWorkspaceCandidates,
|
||||
refreshWorkspaceCandidates,
|
||||
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
} from "../search-cache"
|
||||
|
||||
describe("workspace search cache", () => {
|
||||
beforeEach(() => {
|
||||
clearWorkspaceSearchCache()
|
||||
})
|
||||
|
||||
it("expires cached candidates after the TTL", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
const startTime = 1_000
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
||||
|
||||
const beforeExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||
)
|
||||
assert.ok(beforeExpiry)
|
||||
assert.equal(beforeExpiry.length, 1)
|
||||
assert.equal(beforeExpiry[0].name, "file-a")
|
||||
|
||||
const afterExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||
)
|
||||
assert.equal(afterExpiry, undefined)
|
||||
})
|
||||
|
||||
it("replaces cached entries when manually refreshed", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
||||
const initial = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(initial)
|
||||
assert.equal(initial[0].name, "file-a")
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
||||
const refreshed = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(refreshed)
|
||||
assert.equal(refreshed[0].name, "file-b")
|
||||
})
|
||||
})
|
||||
|
||||
function createEntry(name: string): FileSystemEntry {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
absolutePath: `/tmp/${name}`,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
295
packages/server/src/filesystem/browser.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
FileSystemListingMetadata,
|
||||
WINDOWS_DRIVES_ROOT,
|
||||
} from "../api-types"
|
||||
|
||||
interface FileSystemBrowserOptions {
|
||||
rootDir: string
|
||||
unrestricted?: boolean
|
||||
}
|
||||
|
||||
interface DirectoryReadOptions {
|
||||
includeFiles: boolean
|
||||
formatPath: (entryName: string) => string
|
||||
formatAbsolutePath: (entryName: string) => string
|
||||
}
|
||||
|
||||
const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))
|
||||
|
||||
export class FileSystemBrowser {
|
||||
private readonly root: string
|
||||
private readonly unrestricted: boolean
|
||||
private readonly homeDir: string
|
||||
private readonly isWindows: boolean
|
||||
|
||||
constructor(options: FileSystemBrowserOptions) {
|
||||
this.root = path.resolve(options.rootDir)
|
||||
this.unrestricted = Boolean(options.unrestricted)
|
||||
this.homeDir = os.homedir()
|
||||
this.isWindows = process.platform === "win32"
|
||||
}
|
||||
|
||||
list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("Relative listing is unavailable when running with unrestricted root")
|
||||
}
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
return this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
}
|
||||
|
||||
browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse {
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
if (this.unrestricted) {
|
||||
return this.listUnrestricted(targetPath, includeFiles)
|
||||
}
|
||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
return fs.readFileSync(resolved, "utf-8")
|
||||
}
|
||||
|
||||
private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
const entries = this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "restricted",
|
||||
currentPath: normalizedPath,
|
||||
parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath),
|
||||
rootPath: this.root,
|
||||
homePath: this.homeDir,
|
||||
displayPath: this.resolveRestrictedAbsolute(normalizedPath),
|
||||
pathKind: "relative",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const resolvedPath = this.resolveUnrestrictedPath(targetPath)
|
||||
|
||||
if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) {
|
||||
return this.listWindowsDrives()
|
||||
}
|
||||
|
||||
const entries = this.readDirectoryEntries(resolvedPath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
})
|
||||
|
||||
const parentPath = this.getUnrestrictedParent(resolvedPath)
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: resolvedPath,
|
||||
parentPath,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: resolvedPath,
|
||||
pathKind: "absolute",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listWindowsDrives(): FileSystemListResponse {
|
||||
if (!this.isWindows) {
|
||||
throw new Error("Drive listing is only supported on Windows hosts")
|
||||
}
|
||||
|
||||
const entries: FileSystemEntry[] = []
|
||||
for (const letter of WINDOWS_DRIVE_LETTERS) {
|
||||
const drivePath = `${letter}:\\`
|
||||
try {
|
||||
if (fs.existsSync(drivePath)) {
|
||||
entries.push({
|
||||
name: `${letter}:`,
|
||||
path: drivePath,
|
||||
absolutePath: drivePath,
|
||||
type: "directory",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore inaccessible drives
|
||||
}
|
||||
}
|
||||
|
||||
// Provide a generic UNC root entry so users can navigate to network shares manually.
|
||||
entries.push({
|
||||
name: "UNC Network",
|
||||
path: "\\\\",
|
||||
absolutePath: "\\\\",
|
||||
type: "directory",
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: WINDOWS_DRIVES_ROOT,
|
||||
parentPath: undefined,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: "Drives",
|
||||
pathKind: "drives",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: options.formatPath(entry.name),
|
||||
absolutePath: options.formatAbsolutePath(entry.name),
|
||||
type: isDirectory ? "directory" : "file",
|
||||
size: isDirectory ? undefined : stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
private normalizeRelativePath(input: string | undefined) {
|
||||
if (!input || input === "." || input === "./" || input === "/") {
|
||||
return "."
|
||||
}
|
||||
let normalized = input.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized === "" ? "." : normalized
|
||||
}
|
||||
|
||||
private buildRelativePath(parent: string, child: string) {
|
||||
if (!parent || parent === ".") {
|
||||
return this.normalizeRelativePath(child)
|
||||
}
|
||||
return this.normalizeRelativePath(`${parent}/${child}`)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsolute(relativePath: string) {
|
||||
return this.toRestrictedAbsolute(relativePath)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsoluteChild(parent: string, child: string) {
|
||||
const normalized = this.buildRelativePath(parent, child)
|
||||
return this.toRestrictedAbsolute(normalized)
|
||||
}
|
||||
|
||||
private toRestrictedAbsolute(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
const target = path.resolve(this.root, normalized)
|
||||
const relativeToRoot = path.relative(this.root, target)
|
||||
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
|
||||
throw new Error("Access outside of root is not allowed")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveUnrestrictedPath(input: string | undefined): string {
|
||||
if (!input || input === "." || input === "./") {
|
||||
return this.homeDir
|
||||
}
|
||||
|
||||
if (this.isWindows) {
|
||||
if (input === WINDOWS_DRIVES_ROOT) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
const normalized = path.win32.normalize(input)
|
||||
if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
return path.win32.resolve(this.homeDir, normalized)
|
||||
}
|
||||
|
||||
if (input.startsWith("/")) {
|
||||
return path.posix.normalize(input)
|
||||
}
|
||||
|
||||
return path.posix.resolve(this.homeDir, input)
|
||||
}
|
||||
|
||||
private resolveAbsoluteChild(parent: string, child: string) {
|
||||
if (this.isWindows) {
|
||||
return path.win32.normalize(path.win32.join(parent, child))
|
||||
}
|
||||
return path.posix.normalize(path.posix.join(parent, child))
|
||||
}
|
||||
|
||||
private getRestrictedParent(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
if (normalized === ".") {
|
||||
return undefined
|
||||
}
|
||||
const segments = normalized.split("/")
|
||||
segments.pop()
|
||||
return segments.length === 0 ? "." : segments.join("/")
|
||||
}
|
||||
|
||||
private getUnrestrictedParent(currentPath: string) {
|
||||
if (this.isWindows) {
|
||||
const normalized = path.win32.normalize(currentPath)
|
||||
const parsed = path.win32.parse(normalized)
|
||||
if (normalized === WINDOWS_DRIVES_ROOT) {
|
||||
return undefined
|
||||
}
|
||||
if (normalized === parsed.root) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
return path.win32.dirname(normalized)
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(currentPath)
|
||||
if (normalized === "/") {
|
||||
return undefined
|
||||
}
|
||||
return path.posix.dirname(normalized)
|
||||
}
|
||||
}
|
||||
66
packages/server/src/filesystem/search-cache.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "path"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
|
||||
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||
|
||||
interface WorkspaceCandidateCacheEntry {
|
||||
expiresAt: number
|
||||
candidates: FileSystemEntry[]
|
||||
}
|
||||
|
||||
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
||||
|
||||
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
|
||||
const key = normalizeKey(rootDir)
|
||||
const cached = workspaceCandidateCache.get(key)
|
||||
if (!cached) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (cached.expiresAt <= now) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return cloneEntries(cached.candidates)
|
||||
}
|
||||
|
||||
export function refreshWorkspaceCandidates(
|
||||
rootDir: string,
|
||||
builder: () => FileSystemEntry[],
|
||||
now = Date.now(),
|
||||
): FileSystemEntry[] {
|
||||
const key = normalizeKey(rootDir)
|
||||
const freshCandidates = builder()
|
||||
|
||||
if (!freshCandidates || freshCandidates.length === 0) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return []
|
||||
}
|
||||
|
||||
const storedCandidates = cloneEntries(freshCandidates)
|
||||
workspaceCandidateCache.set(key, {
|
||||
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
candidates: storedCandidates,
|
||||
})
|
||||
|
||||
return cloneEntries(storedCandidates)
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||
if (typeof rootDir === "undefined") {
|
||||
workspaceCandidateCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeKey(rootDir)
|
||||
workspaceCandidateCache.delete(key)
|
||||
}
|
||||
|
||||
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||
return entries.map((entry) => ({ ...entry }))
|
||||
}
|
||||
|
||||
function normalizeKey(rootDir: string) {
|
||||
return path.resolve(rootDir)
|
||||
}
|
||||
184
packages/server/src/filesystem/search.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 200
|
||||
const MAX_CANDIDATES = 8000
|
||||
const IGNORED_DIRECTORIES = new Set(
|
||||
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
|
||||
(name) => name.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
||||
export type WorkspaceFileSearchType = "all" | "file" | "directory"
|
||||
|
||||
export interface WorkspaceFileSearchOptions {
|
||||
limit?: number
|
||||
type?: WorkspaceFileSearchType
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
interface CandidateEntry {
|
||||
entry: FileSystemEntry
|
||||
key: string
|
||||
}
|
||||
|
||||
export function searchWorkspaceFiles(
|
||||
rootDir: string,
|
||||
query: string,
|
||||
options: WorkspaceFileSearchOptions = {},
|
||||
): FileSystemEntry[] {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
throw new Error("Search query is required")
|
||||
}
|
||||
|
||||
const normalizedRoot = path.resolve(rootDir)
|
||||
const limit = normalizeLimit(options.limit)
|
||||
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||
const refreshRequested = options.refresh === true
|
||||
|
||||
let entries: FileSystemEntry[] | undefined
|
||||
|
||||
try {
|
||||
if (!refreshRequested) {
|
||||
entries = getWorkspaceCandidates(normalizedRoot)
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = buildCandidateEntries(entries, typeFilter)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
|
||||
key: "key",
|
||||
limit,
|
||||
})
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return matches.map((match) => match.obj.entry)
|
||||
}
|
||||
|
||||
|
||||
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||
const queue: string[] = [""]
|
||||
const entries: FileSystemEntry[] = []
|
||||
|
||||
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
||||
const relativeDir = queue.pop() || ""
|
||||
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||
|
||||
let dirents: fs.Dirent[]
|
||||
try {
|
||||
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const entryName = dirent.name
|
||||
const lowerName = entryName.toLowerCase()
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
|
||||
const absolutePath = path.join(absoluteDir, entryName)
|
||||
|
||||
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absolutePath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = stats.isDirectory()
|
||||
|
||||
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
if (entries.length < MAX_CANDIDATES) {
|
||||
queue.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||
const normalizedPath = normalizeRelativeEntryPath(relativePath)
|
||||
const entry: FileSystemEntry = {
|
||||
name: entryName,
|
||||
path: normalizedPath,
|
||||
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
|
||||
type: entryType,
|
||||
size: entryType === "file" ? stats.size : undefined,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= MAX_CANDIDATES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
|
||||
const filtered: CandidateEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (!shouldInclude(entry.type, filter)) {
|
||||
continue
|
||||
}
|
||||
filtered.push({ entry, key: buildSearchKey(entry) })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number) {
|
||||
if (!limit || Number.isNaN(limit)) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
|
||||
return clamped
|
||||
}
|
||||
|
||||
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
|
||||
return filter === "all" || entryType === filter
|
||||
}
|
||||
|
||||
function normalizeRelativeEntryPath(relativePath: string): string {
|
||||
if (!relativePath) {
|
||||
return "."
|
||||
}
|
||||
let normalized = relativePath.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized || "."
|
||||
}
|
||||
|
||||
function buildSearchKey(entry: FileSystemEntry) {
|
||||
return entry.path.toLowerCase()
|
||||
}
|
||||
207
packages/server/src/index.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* CLI entry point.
|
||||
* For now this only wires the typed modules together; actual command handling comes later.
|
||||
*/
|
||||
import { Command, InvalidArgumentError, Option } from "commander"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
import { BinaryRegistry } from "./config/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
launch: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
const DEFAULT_HOST = "127.0.0.1"
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
.name("codenomad")
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
|
||||
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
|
||||
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
|
||||
.addOption(
|
||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
port: number
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
config: string
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
launch?: boolean
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: normalizedHost,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
logLevel: parsed.logLevel,
|
||||
logDestination: parsed.logDestination,
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
launch: Boolean(parsed.launch),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(input: string): number {
|
||||
const value = Number(input)
|
||||
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveHost(input: string | undefined): string {
|
||||
if (input && input.trim() === "0.0.0.0") {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return DEFAULT_HOST
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
||||
const workspaceLogger = logger.child({ component: "workspace" })
|
||||
const configLogger = logger.child({ component: "config" })
|
||||
const eventLogger = logger.child({ component: "events" })
|
||||
|
||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
logger,
|
||||
})
|
||||
|
||||
const startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
|
||||
if (options.launch) {
|
||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) {
|
||||
logger.info("Shutdown already in progress, ignoring signal")
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, closing server")
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const logger = createLogger({ component: "app" })
|
||||
logger.error({ err: error }, "CLI server crashed")
|
||||
process.exit(1)
|
||||
})
|
||||
177
packages/server/src/launcher.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { spawn } from "child_process"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Logger } from "./logger"
|
||||
|
||||
interface BrowserCandidate {
|
||||
name: string
|
||||
command: string
|
||||
args: (url: string) => string[]
|
||||
}
|
||||
|
||||
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
|
||||
|
||||
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
|
||||
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
|
||||
|
||||
console.log(`Attempting to launch browser (${platform}) using:`)
|
||||
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const success = await tryLaunch(candidate, url, logger)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
|
||||
)
|
||||
if (manualExamples.length > 0) {
|
||||
console.error("Manual launch commands:")
|
||||
manualExamples.forEach((line) => console.error(` ${line}`))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
try {
|
||||
const args = candidate.args(url)
|
||||
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
child.once("spawn", () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.info(
|
||||
{
|
||||
browser: candidate.name,
|
||||
command: candidate.command,
|
||||
args,
|
||||
fullCommand: [candidate.command, ...args].join(" "),
|
||||
},
|
||||
"Launched browser in app mode",
|
||||
)
|
||||
child.unref()
|
||||
resolve(true)
|
||||
})
|
||||
} catch (error) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildPlatformCandidates(url: string) {
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
return {
|
||||
platform: "macOS",
|
||||
candidates: buildMacCandidates(),
|
||||
manualExamples: buildMacManualExamples(url),
|
||||
}
|
||||
case "win32":
|
||||
return {
|
||||
platform: "Windows",
|
||||
candidates: buildWindowsCandidates(),
|
||||
manualExamples: buildWindowsManualExamples(url),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
platform: "Linux",
|
||||
candidates: buildLinuxCandidates(),
|
||||
manualExamples: buildLinuxManualExamples(url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMacCandidates(): BrowserCandidate[] {
|
||||
const apps = [
|
||||
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
||||
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
|
||||
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
||||
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
||||
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
||||
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
|
||||
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
|
||||
]
|
||||
|
||||
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildWindowsCandidates(): BrowserCandidate[] {
|
||||
const programFiles = process.env["ProgramFiles"]
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"]
|
||||
const localAppData = process.env["LocalAppData"]
|
||||
|
||||
const paths = [
|
||||
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
|
||||
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
|
||||
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
|
||||
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
|
||||
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
|
||||
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
|
||||
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
|
||||
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
|
||||
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
|
||||
] as const
|
||||
|
||||
return paths
|
||||
.filter(([root]) => Boolean(root))
|
||||
.map(([root, rel, name]) => ({
|
||||
name,
|
||||
command: path.join(root as string, rel),
|
||||
args: APP_ARGS,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildLinuxCandidates(): BrowserCandidate[] {
|
||||
const names = [
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"brave-browser",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-stable",
|
||||
"vivaldi",
|
||||
]
|
||||
|
||||
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildMacManualExamples(url: string) {
|
||||
return [
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
|
||||
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildWindowsManualExamples(url: string) {
|
||||
return [
|
||||
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinuxManualExamples(url: string) {
|
||||
return [
|
||||
`google-chrome --app="${url}" --new-window`,
|
||||
`chromium --app="${url}" --new-window`,
|
||||
`brave-browser --app="${url}" --new-window`,
|
||||
`microsoft-edge --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
21
packages/server/src/loader.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export async function resolve(specifier: string, context: any, defaultResolve: any) {
|
||||
try {
|
||||
return await defaultResolve(specifier, context, defaultResolve)
|
||||
} catch (error: any) {
|
||||
if (shouldRetry(specifier, error)) {
|
||||
const retried = specifier.endsWith(".js") ? specifier : `${specifier}.js`
|
||||
return defaultResolve(retried, context, defaultResolve)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetry(specifier: string, error: any) {
|
||||
if (!error || error.code !== "ERR_MODULE_NOT_FOUND") {
|
||||
return false
|
||||
}
|
||||
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
133
packages/server/src/logger.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Transform } from "node:stream"
|
||||
import pino, { Logger as PinoLogger } from "pino"
|
||||
|
||||
export type Logger = PinoLogger
|
||||
|
||||
interface LoggerOptions {
|
||||
level?: string
|
||||
destination?: string
|
||||
component?: string
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<number, string> = {
|
||||
10: "trace",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
}
|
||||
|
||||
const LIFECYCLE_COMPONENTS = new Set(["app", "workspace"])
|
||||
const OMITTED_FIELDS = new Set(["time", "msg", "level", "component", "module"])
|
||||
|
||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
|
||||
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
|
||||
const baseComponent = options.component ?? "app"
|
||||
const loggerOptions = {
|
||||
level,
|
||||
base: { component: baseComponent },
|
||||
timestamp: false,
|
||||
} as const
|
||||
|
||||
if (destination && destination !== "stdout") {
|
||||
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
|
||||
return pino(loggerOptions, stream)
|
||||
}
|
||||
|
||||
const lifecycleStream = new LifecycleLogStream({ restrictInfoToLifecycle: level === "info" })
|
||||
lifecycleStream.pipe(process.stdout)
|
||||
return pino(loggerOptions, lifecycleStream)
|
||||
}
|
||||
|
||||
interface LifecycleStreamOptions {
|
||||
restrictInfoToLifecycle: boolean
|
||||
}
|
||||
|
||||
class LifecycleLogStream extends Transform {
|
||||
private buffer = ""
|
||||
|
||||
constructor(private readonly options: LifecycleStreamOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: () => void) {
|
||||
this.buffer += chunk.toString()
|
||||
let newlineIndex = this.buffer.indexOf("\n")
|
||||
while (newlineIndex >= 0) {
|
||||
const line = this.buffer.slice(0, newlineIndex)
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1)
|
||||
this.pushFormatted(line)
|
||||
newlineIndex = this.buffer.indexOf("\n")
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
_flush(callback: () => void) {
|
||||
if (this.buffer.length > 0) {
|
||||
this.pushFormatted(this.buffer)
|
||||
this.buffer = ""
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
private pushFormatted(line: string) {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
let entry: Record<string, unknown>
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const levelNumber = typeof entry.level === "number" ? entry.level : 30
|
||||
const levelLabel = LEVEL_LABELS[levelNumber] ?? "info"
|
||||
const component = (entry.component as string | undefined) ?? (entry.module as string | undefined) ?? "app"
|
||||
|
||||
if (this.options.restrictInfoToLifecycle && levelNumber <= 30 && !LIFECYCLE_COMPONENTS.has(component)) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = typeof entry.msg === "string" ? entry.msg : ""
|
||||
const metadata = this.formatMetadata(entry)
|
||||
const formatted = metadata.length > 0 ? `[${levelLabel.toUpperCase()}] [${component}] ${message} ${metadata}` : `[${levelLabel.toUpperCase()}] [${component}] ${message}`
|
||||
this.push(`${formatted}\n`)
|
||||
}
|
||||
|
||||
private formatMetadata(entry: Record<string, unknown>): string {
|
||||
const pairs: string[] = []
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (OMITTED_FIELDS.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === "err" && value && typeof value === "object") {
|
||||
const err = value as { type?: string; message?: string; stack?: string }
|
||||
const errLabel = err.type ?? "Error"
|
||||
const errMessage = err.message ? `: ${err.message}` : ""
|
||||
pairs.push(`err=${errLabel}${errMessage}`)
|
||||
if (err.stack) {
|
||||
pairs.push(`stack="${err.stack}"`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pairs.push(`${key}=${this.stringifyValue(value)}`)
|
||||
}
|
||||
|
||||
return pairs.join(" ").trim()
|
||||
}
|
||||
|
||||
private stringifyValue(value: unknown): string {
|
||||
if (value === undefined) return "undefined"
|
||||
if (value === null) return "null"
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Error) return value.message ?? value.name
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
305
packages/server/src/server/http-server.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||
import { registerConfigRoutes } from "./routes/config"
|
||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
port: number
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface HttpServerStartResult {
|
||||
port: number
|
||||
url: string
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
|
||||
const sseClients = new Set<() => void>()
|
||||
const registerSseClient = (cleanup: () => void) => {
|
||||
sseClients.add(cleanup)
|
||||
return () => sseClients.delete(cleanup)
|
||||
}
|
||||
const closeSseClients = () => {
|
||||
for (const cleanup of Array.from(sseClients)) {
|
||||
cleanup()
|
||||
}
|
||||
sseClients.clear()
|
||||
}
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
app.register(replyFrom, {
|
||||
contentTypesToEncode: [],
|
||||
undici: {
|
||||
connections: 16,
|
||||
pipelining: 1,
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
},
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
} else {
|
||||
setupStaticUi(app, deps.uiStaticDir)
|
||||
}
|
||||
|
||||
return {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const addressInfo = await app.listen({ port: deps.port, host: deps.host })
|
||||
|
||||
let actualPort = deps.port
|
||||
|
||||
if (typeof addressInfo === "string") {
|
||||
try {
|
||||
const parsed = new URL(addressInfo)
|
||||
actualPort = Number(parsed.port) || deps.port
|
||||
} catch {
|
||||
actualPort = deps.port
|
||||
}
|
||||
} else {
|
||||
const address = app.server.address()
|
||||
if (typeof address === "object" && address) {
|
||||
actualPort = address.port
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface InstanceProxyDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const port = workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
reply.code(502).send({ error: "Workspace instance is not ready" })
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!proxyReply.sent) {
|
||||
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
}
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uiDir)) {
|
||||
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
|
||||
return
|
||||
}
|
||||
|
||||
app.register(fastifyStatic, {
|
||||
root: uiDir,
|
||||
prefix: "/",
|
||||
decorateReply: false,
|
||||
})
|
||||
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
if (isApiRequest(url)) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
} else {
|
||||
reply.code(404).send({ message: "UI bundle missing" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
if (isApiRequest(url)) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
void proxyToDevServer(request, reply, upstreamBase)
|
||||
})
|
||||
}
|
||||
|
||||
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
|
||||
try {
|
||||
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: buildProxyHeaders(request.headers),
|
||||
})
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
reply.header(key, value)
|
||||
})
|
||||
|
||||
reply.code(response.status)
|
||||
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
reply.send(buffer)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send("UI dev server is unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isApiRequest(rawUrl: string | null | undefined) {
|
||||
if (!rawUrl) return false
|
||||
const pathname = rawUrl.split("?")[0] ?? ""
|
||||
return pathname === "/api" || pathname.startsWith("/api/")
|
||||
}
|
||||
|
||||
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
if (!value || key.toLowerCase() === "host") continue
|
||||
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||
}
|
||||
return result
|
||||
}
|
||||
62
packages/server/src/server/routes/config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
import { ConfigFileSchema } from "../../config/schema"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
}
|
||||
|
||||
const BinaryCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryUpdateSchema = z.object({
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryValidateSchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/config/app", async () => deps.configStore.get())
|
||||
|
||||
app.put("/api/config/app", async (request) => {
|
||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||
deps.configStore.replace(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
app.get("/api/config/binaries", async () => {
|
||||
return { binaries: deps.binaryRegistry.list() }
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries", async (request, reply) => {
|
||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.create(body)
|
||||
reply.code(201)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||
deps.binaryRegistry.remove(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries/validate", async (request) => {
|
||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||
return deps.binaryRegistry.validatePath(body.path)
|
||||
})
|
||||
}
|
||||
49
packages/server/src/server/routes/events.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
}
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const origin = request.headers.origin ?? "*"
|
||||
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const send = (event: WorkspaceEventPayload) => {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||
}, 15000)
|
||||
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
clearInterval(heartbeat)
|
||||
unsubscribe()
|
||||
reply.raw.end?.()
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
}
|
||||
|
||||
request.raw.on("close", handleClose)
|
||||
request.raw.on("error", handleClose)
|
||||
})
|
||||
}
|
||||
27
packages/server/src/server/routes/filesystem.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { FileSystemBrowser } from "../../filesystem/browser"
|
||||
|
||||
interface RouteDeps {
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
}
|
||||
|
||||
const FilesystemQuerySchema = z.object({
|
||||
path: z.string().optional(),
|
||||
includeFiles: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/filesystem", async (request, reply) => {
|
||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||
|
||||
try {
|
||||
return deps.fileSystemBrowser.browse(query.path, {
|
||||
includeFiles: query.includeFiles,
|
||||
})
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
})
|
||||
}
|
||||
10
packages/server/src/server/routes/meta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/meta", async () => deps.serverMeta)
|
||||
}
|
||||
66
packages/server/src/server/routes/storage.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { InstanceStore } from "../../storage/instance-store"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { ModelPreferenceSchema } from "../../config/schema"
|
||||
import type { InstanceData } from "../../api-types"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
instanceStore: InstanceStore
|
||||
eventBus: EventBus
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const InstanceDataSchema = z.object({
|
||||
messageHistory: z.array(z.string()).default([]),
|
||||
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
|
||||
})
|
||||
|
||||
const EMPTY_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
}
|
||||
|
||||
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const resolveStorageKey = (instanceId: string): string => {
|
||||
const workspace = deps.workspaceManager.get(instanceId)
|
||||
return workspace?.path ?? instanceId
|
||||
}
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
const data = await deps.instanceStore.read(storageId)
|
||||
return data
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
return { error: error instanceof Error ? error.message : "Failed to read instance data" }
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const body = InstanceDataSchema.parse(request.body ?? {})
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
await deps.instanceStore.write(storageId, body)
|
||||
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to save instance data" }
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
await deps.instanceStore.delete(storageId)
|
||||
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
|
||||
}
|
||||
})
|
||||
}
|
||||
107
packages/server/src/server/routes/workspaces.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const WorkspaceCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
const WorkspaceFilesQuerySchema = z.object({
|
||||
path: z.string().optional(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
type: z.enum(["all", "file", "directory"]).optional(),
|
||||
refresh: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === undefined ? undefined : value === "true")),
|
||||
})
|
||||
|
||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/workspaces", async () => {
|
||||
return deps.workspaceManager.list()
|
||||
})
|
||||
|
||||
app.post("/api/workspaces", async (request, reply) => {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
return workspace
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
await deps.workspaceManager.delete(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFilesQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".")
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
|
||||
}>("/api/workspaces/:id/files/search", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
|
||||
limit: query.limit,
|
||||
type: query.type,
|
||||
refresh: query.refresh,
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.readFile(request.params.id, query.path)
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof Error && error.message === "Workspace not found") {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||
}
|
||||
64
packages/server/src/storage/instance-store.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { InstanceData } from "../api-types"
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
}
|
||||
|
||||
export class InstanceStore {
|
||||
private readonly instancesDir: string
|
||||
|
||||
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
|
||||
this.instancesDir = baseDir
|
||||
fs.mkdirSync(this.instancesDir, { recursive: true })
|
||||
}
|
||||
|
||||
async read(id: string): Promise<InstanceData> {
|
||||
try {
|
||||
const filePath = this.resolvePath(id)
|
||||
const content = await fsp.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
return { ...DEFAULT_INSTANCE_DATA, ...parsed }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return DEFAULT_INSTANCE_DATA
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async write(id: string, data: InstanceData): Promise<void> {
|
||||
const filePath = this.resolvePath(id)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const filePath = this.resolvePath(id)
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(id: string): string {
|
||||
const filename = this.sanitizeId(id)
|
||||
return path.join(this.instancesDir, `${filename}.json`)
|
||||
}
|
||||
|
||||
private sanitizeId(id: string): string {
|
||||
return id
|
||||
.replace(/[\\/]/g, "_")
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.toLowerCase()
|
||||
}
|
||||
}
|
||||
190
packages/server/src/workspaces/instance-events.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import { Agent as UndiciAgent } from "undici"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "./manager"
|
||||
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||
|
||||
const INSTANCE_HOST = "127.0.0.1"
|
||||
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
|
||||
const RECONNECT_DELAY_MS = 1000
|
||||
|
||||
interface InstanceEventBridgeOptions {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface ActiveStream {
|
||||
controller: AbortController
|
||||
task: Promise<void>
|
||||
}
|
||||
|
||||
export class InstanceEventBridge {
|
||||
private readonly streams = new Map<string, ActiveStream>()
|
||||
|
||||
constructor(private readonly options: InstanceEventBridgeOptions) {
|
||||
const bus = this.options.eventBus
|
||||
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
|
||||
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
|
||||
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [id, active] of this.streams) {
|
||||
active.controller.abort()
|
||||
this.publishStatus(id, "disconnected")
|
||||
}
|
||||
this.streams.clear()
|
||||
}
|
||||
|
||||
private startStream(workspaceId: string) {
|
||||
if (this.streams.has(workspaceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const task = this.runStream(workspaceId, controller.signal)
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
|
||||
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
const active = this.streams.get(workspaceId)
|
||||
if (active?.controller === controller) {
|
||||
this.streams.delete(workspaceId)
|
||||
}
|
||||
})
|
||||
|
||||
this.streams.set(workspaceId, { controller, task })
|
||||
}
|
||||
|
||||
private stopStream(workspaceId: string, reason?: string) {
|
||||
const active = this.streams.get(workspaceId)
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
active.controller.abort()
|
||||
this.streams.delete(workspaceId)
|
||||
this.publishStatus(workspaceId, "disconnected", reason)
|
||||
}
|
||||
|
||||
private async runStream(workspaceId: string, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
const port = this.options.workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||
continue
|
||||
}
|
||||
|
||||
this.publishStatus(workspaceId, "connecting")
|
||||
|
||||
try {
|
||||
await this.consumeStream(workspaceId, port, signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
|
||||
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal,
|
||||
dispatcher: STREAM_AGENT,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Instance event stream unavailable (${response.status})`)
|
||||
}
|
||||
|
||||
this.publishStatus(workspaceId, "connected")
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) {
|
||||
break
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
buffer = this.flushEvents(buffer, workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
private flushEvents(buffer: string, workspaceId: string) {
|
||||
let separatorIndex = buffer.indexOf("\n\n")
|
||||
|
||||
while (separatorIndex >= 0) {
|
||||
const chunk = buffer.slice(0, separatorIndex)
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
this.processChunk(chunk, workspaceId)
|
||||
separatorIndex = buffer.indexOf("\n\n")
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private processChunk(chunk: string, workspaceId: string) {
|
||||
const lines = chunk.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(":")) {
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = dataLines.join("\n").trim()
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
|
||||
}
|
||||
}
|
||||
|
||||
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
|
||||
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
|
||||
}
|
||||
|
||||
private delay(duration: number, signal: AbortSignal) {
|
||||
if (duration <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, duration)
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
256
packages/server/src/workspaces/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
}
|
||||
|
||||
list(): WorkspaceDescriptor[] {
|
||||
return Array.from(this.workspaces.values())
|
||||
}
|
||||
|
||||
get(id: string): WorkspaceDescriptor | undefined {
|
||||
return this.workspaces.get(id)
|
||||
}
|
||||
|
||||
getInstancePort(id: string): number | undefined {
|
||||
return this.workspaces.get(id)?.port
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
return browser.list(relativePath)
|
||||
}
|
||||
|
||||
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
return searchWorkspaceFiles(workspace.path, query, options)
|
||||
}
|
||||
|
||||
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
const contents = browser.readFile(relativePath)
|
||||
return {
|
||||
workspaceId,
|
||||
relativePath,
|
||||
contents,
|
||||
}
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
id,
|
||||
path: workspacePath,
|
||||
name,
|
||||
status: "starting",
|
||||
proxyPath,
|
||||
binaryId: resolvedBinaryPath,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||
|
||||
try {
|
||||
const { pid, port } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
|
||||
return descriptor
|
||||
} catch (error) {
|
||||
descriptor.status = "error"
|
||||
descriptor.error = error instanceof Error ? error.message : String(error)
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
|
||||
const workspace = this.workspaces.get(id)
|
||||
if (!workspace) return undefined
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
|
||||
const wasRunning = Boolean(workspace.pid)
|
||||
if (wasRunning) {
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
|
||||
})
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (workspace.pid) {
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
})
|
||||
} else {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
}
|
||||
}
|
||||
this.workspaces.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
}
|
||||
|
||||
private requireWorkspace(id: string): WorkspaceRecord {
|
||||
const workspace = this.workspaces.get(id)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
private resolveBinaryPath(identifier: string): string {
|
||||
if (!identifier) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
|
||||
if (path.isAbsolute(identifier) || looksLikePath) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
const locator = process.platform === "win32" ? "where" : "which"
|
||||
|
||||
try {
|
||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const resolved = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
|
||||
if (resolved) {
|
||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||
return resolved
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
||||
if (line) {
|
||||
const normalized = line.trim()
|
||||
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1]
|
||||
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
||||
return version
|
||||
}
|
||||
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
||||
return normalized
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||
|
||||
workspace.pid = undefined
|
||||
workspace.port = undefined
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
|
||||
if (info.requested || info.code === 0) {
|
||||
workspace.status = "stopped"
|
||||
workspace.error = undefined
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
|
||||
} else {
|
||||
workspace.status = "error"
|
||||
workspace.error = `Process exited with code ${info.code}`
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace })
|
||||
}
|
||||
}
|
||||
}
|
||||
217
packages/server/src/workspaces/runtime.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
interface LaunchOptions {
|
||||
workspaceId: string
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
interface ProcessExitInfo {
|
||||
workspaceId: string
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
requested: boolean
|
||||
}
|
||||
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
private processes = new Map<string, ManagedProcess>()
|
||||
|
||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
||||
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info(
|
||||
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
let warningTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const startWarningTimer = () => {
|
||||
warningTimer = setInterval(() => {
|
||||
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopWarningTimer = () => {
|
||||
if (warningTimer) {
|
||||
clearInterval(warningTimer)
|
||||
warningTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
startWarningTimer()
|
||||
|
||||
const cleanupStreams = () => {
|
||||
stopWarningTimer()
|
||||
child.stdout?.removeAllListeners()
|
||||
child.stderr?.removeAllListeners()
|
||||
}
|
||||
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
|
||||
this.processes.delete(options.workspaceId)
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
child.removeListener("exit", handleExit)
|
||||
if (!portFound) {
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
} else {
|
||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
cleanupStreams()
|
||||
child.removeListener("exit", handleExit)
|
||||
this.processes.delete(options.workspaceId)
|
||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||
reject(error)
|
||||
}
|
||||
|
||||
child.on("error", handleError)
|
||||
child.on("exit", handleExit)
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
this.emitLog(options.workspaceId, "info", line)
|
||||
|
||||
if (!portFound) {
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||
if (portMatch) {
|
||||
portFound = true
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
||||
resolve({ pid: child.pid!, port })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
this.emitLog(options.workspaceId, "error", line)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(workspaceId: string): Promise<void> {
|
||||
const managed = this.processes.get(workspaceId)
|
||||
if (!managed) return
|
||||
|
||||
managed.requestedStop = true
|
||||
const child = managed.child
|
||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
child.removeListener("exit", onExit)
|
||||
child.removeListener("error", onError)
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const resolveIfAlreadyExited = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||
cleanup()
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
child.once("exit", onExit)
|
||||
child.once("error", onError)
|
||||
|
||||
if (resolveIfAlreadyExited()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
||||
child.kill("SIGTERM")
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
||||
child.kill("SIGKILL")
|
||||
} else {
|
||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
private emitLog(workspaceId: string, level: LogLevel, message: string) {
|
||||
const entry: WorkspaceLogEntry = {
|
||||
workspaceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: message.trim(),
|
||||
}
|
||||
|
||||
this.eventBus.publish({ type: "workspace.log", entry })
|
||||
}
|
||||
|
||||
private validateFolder(folder: string) {
|
||||
const resolved = path.resolve(folder)
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`Folder does not exist: ${resolved}`)
|
||||
}
|
||||
const stats = statSync(resolved)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolved}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
7
packages/tauri-app/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
src-tauri/target
|
||||
src-tauri/Cargo.lock
|
||||
src-tauri/resources/
|
||||
target
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
5282
packages/tauri-app/Cargo.lock
generated
Normal file
3
packages/tauri-app/Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["src-tauri"]
|
||||
resolver = "2"
|
||||
17
packages/tauri-app/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
}
|
||||
46
packages/tauri-app/scripts/dev-prep.js
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[dev-prep] UI loader build missing; running workspace build…")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(loadingHtml)) {
|
||||
throw new Error("[dev-prep] failed to produce loading.html after UI build")
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
|
||||
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||
|
||||
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||
if (fs.existsSync(assetsSource)) {
|
||||
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
158
packages/tauri-app/scripts/prebuild.js
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const serverRoot = path.resolve(root, "..", "server")
|
||||
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 sources = ["dist", "public", "node_modules", "package.json"]
|
||||
|
||||
const serverInstallCommand =
|
||||
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
||||
const serverDevInstallCommand =
|
||||
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm ci --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
}
|
||||
|
||||
const braceExpansionPath = path.join(
|
||||
serverRoot,
|
||||
"node_modules",
|
||||
"@fastify",
|
||||
"static",
|
||||
"node_modules",
|
||||
"brace-expansion",
|
||||
"package.json",
|
||||
)
|
||||
|
||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||
throw new Error("[prebuild] server artifacts still missing after build")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ui build missing; running workspace build...")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(loadingHtml)) {
|
||||
throw new Error("[prebuild] ui loading assets missing after build")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server build dependencies (with dev)...")
|
||||
execSync(serverDevInstallCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
function ensureUiDevDependencies() {
|
||||
if (fs.existsSync(viteBinPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring ui build dependencies...")
|
||||
execSync(uiDevInstallCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
|
||||
for (const name of sources) {
|
||||
const from = path.join(serverRoot, name)
|
||||
const to = path.join(serverDest, name)
|
||||
if (!fs.existsSync(from)) {
|
||||
console.warn(`[prebuild] skipped missing ${from}`)
|
||||
continue
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||
console.log(`[prebuild] copied ${from} -> ${to}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
|
||||
if (!fs.existsSync(loadingSource)) {
|
||||
throw new Error("[prebuild] cannot find built loading.html")
|
||||
}
|
||||
|
||||
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||
|
||||
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||
if (fs.existsSync(assetsSource)) {
|
||||
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
ensureServerDependencies()
|
||||
copyServerArtifacts()
|
||||
copyUiLoadingAssets()
|
||||
20
packages/tauri-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
tauri-plugin-dialog = "2"
|
||||
3
packages/tauri-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||
"identifier": "main-window-native-dialogs",
|
||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://127.0.0.1:*",
|
||||
"http://localhost:*"
|
||||
]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
||||
2310
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2310
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
636
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
@@ -0,0 +1,636 @@
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
if let Some(parent) = dir.parent() {
|
||||
dir = parent.to_path_buf();
|
||||
}
|
||||
}
|
||||
Some(dir)
|
||||
})
|
||||
}
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
log_line(&format!("navigating main to {url}"));
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
let _ = win.navigate(parsed);
|
||||
} else {
|
||||
log_line("failed to parse URL for navigation");
|
||||
}
|
||||
} else {
|
||||
log_line("main window not found for navigation");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CliState {
|
||||
Starting,
|
||||
Ready,
|
||||
Error,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CliStatus {
|
||||
pub state: CliState,
|
||||
pub pid: Option<u32>,
|
||||
pub port: Option<u16>,
|
||||
pub url: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CliStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: CliState::Stopped,
|
||||
pid: None,
|
||||
port: None,
|
||||
url: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CliProcessManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop()?;
|
||||
self.ready.store(false, Ordering::SeqCst);
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
status.pid = None;
|
||||
}
|
||||
Self::emit_status(&app, &self.status.lock());
|
||||
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
||||
log_line(&format!("cli spawn failed: {err}"));
|
||||
let mut locked = status_arc.lock();
|
||||
locked.state = CliState::Error;
|
||||
locked.error = Some(err.to_string());
|
||||
let snapshot = locked.clone();
|
||||
drop(locked);
|
||||
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
|
||||
let _ = app.emit("cli:status", snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Stopped;
|
||||
status.pid = None;
|
||||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self) -> CliStatus {
|
||||
self.status.lock().clone()
|
||||
}
|
||||
|
||||
fn spawn_cli(
|
||||
app: AppHandle,
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
dev: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
log_line("resolving CLI entry");
|
||||
let resolution = CliEntry::resolve(&app, dev)?;
|
||||
log_line(&format!(
|
||||
"resolved CLI entry runner={:?} entry={}",
|
||||
resolution.runner, resolution.entry
|
||||
));
|
||||
let args = resolution.build_args(dev);
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
}
|
||||
|
||||
let cwd = workspace_root();
|
||||
if let Some(ref c) = cwd {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
log_line("spawning directly with node");
|
||||
ShellCommandType::Direct(DirectCommand {
|
||||
program: resolution.node_binary.clone(),
|
||||
args: resolution.runner_args(&args),
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
||||
}
|
||||
}
|
||||
|
||||
let child = match &command_info {
|
||||
ShellCommandType::UserShell(cmd) => {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||
let mut c = Command::new(&cmd.program);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
|
||||
let pid = child.id();
|
||||
log_line(&format!("spawned pid={pid}"));
|
||||
{
|
||||
let mut locked = status.lock();
|
||||
locked.pid = Some(pid);
|
||||
}
|
||||
Self::emit_status(&app, &status.lock());
|
||||
|
||||
{
|
||||
let mut holder = child_holder.lock();
|
||||
*holder = Some(child);
|
||||
}
|
||||
|
||||
let child_clone = child_holder.clone();
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
.and_then(|c| c.stdout.take())
|
||||
.map(BufReader::new);
|
||||
let stderr = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
.and_then(|c| c.stderr.take())
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||
}
|
||||
});
|
||||
|
||||
let app_clone = app.clone();
|
||||
let status_clone = status.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(15);
|
||||
thread::sleep(timeout);
|
||||
if ready_clone.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let mut locked = status_clone.lock();
|
||||
locked.state = CliState::Error;
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
});
|
||||
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
thread::spawn(move || {
|
||||
let code = {
|
||||
let mut guard = child_holder.lock();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
child.wait().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut locked = status_clone.lock();
|
||||
let failed = locked.state != CliState::Ready;
|
||||
let err_msg = if failed {
|
||||
Some(match code {
|
||||
Some(status) => format!("CLI exited early: {status}"),
|
||||
None => "CLI exited early".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if failed {
|
||||
locked.state = CliState::Error;
|
||||
if locked.error.is_none() {
|
||||
locked.error = err_msg.clone();
|
||||
}
|
||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
||||
} else {
|
||||
locked.state = CliState::Stopped;
|
||||
log_line("cli process stopped cleanly");
|
||||
}
|
||||
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_stream<R: BufRead>(
|
||||
mut reader: R,
|
||||
stream: &str,
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
match reader.read_line(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let line = buffer.trim_end();
|
||||
if !line.is_empty() {
|
||||
log_line(&format!("[cli][{}] {}", stream, line));
|
||||
|
||||
if ready.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(port) = port_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(app, status, ready, port as u16);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let mut locked = status.lock();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
locked.port = Some(port);
|
||||
locked.url = Some(url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
log_line(&format!("cli ready on {url}"));
|
||||
navigate_main(app, &url);
|
||||
let _ = app.emit("cli:ready", locked.clone());
|
||||
Self::emit_status(app, &locked);
|
||||
}
|
||||
|
||||
fn emit_status(app: &AppHandle, status: &CliStatus) {
|
||||
let _ = app.emit("cli:status", status.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_user_shell() -> bool {
|
||||
cfg!(unix)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShellCommand {
|
||||
shell: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DirectCommand {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ShellCommandType {
|
||||
UserShell(ShellCommand),
|
||||
Direct(DirectCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CliEntry {
|
||||
entry: String,
|
||||
runner: Runner,
|
||||
runner_path: Option<String>,
|
||||
node_binary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Runner {
|
||||
Node,
|
||||
Tsx,
|
||||
}
|
||||
|
||||
impl CliEntry {
|
||||
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
|
||||
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
||||
|
||||
if dev {
|
||||
if let Some(tsx_path) = resolve_tsx(app) {
|
||||
if let Some(entry) = resolve_dev_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Tsx,
|
||||
runner_path: Some(tsx_path),
|
||||
node_binary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = resolve_dist_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Node,
|
||||
runner_path: None,
|
||||
node_binary,
|
||||
});
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
if dev {
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||
let mut args = VecDeque::new();
|
||||
if self.runner == Runner::Tsx {
|
||||
if let Some(path) = &self.runner_path {
|
||||
args.push_back(path.clone());
|
||||
}
|
||||
}
|
||||
args.push_back(self.entry.clone());
|
||||
for arg in cli_args {
|
||||
args.push_back(arg.clone());
|
||||
}
|
||||
args.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
let base = workspace_root();
|
||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let resources = dir.join("../Resources");
|
||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
||||
|
||||
let shell = default_shell();
|
||||
let mut quoted: Vec<String> = Vec::new();
|
||||
quoted.push(shell_escape(&entry.node_binary));
|
||||
for arg in entry.runner_args(cli_args) {
|
||||
quoted.push(shell_escape(&arg));
|
||||
}
|
||||
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
||||
let args = build_shell_args(&shell, &command);
|
||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||
Ok(ShellCommand { shell, args })
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
if !shell.trim().is_empty() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
"/bin/zsh".to_string()
|
||||
} else {
|
||||
"/bin/bash".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(input: &str) -> String {
|
||||
if input.is_empty() {
|
||||
"''".to_string()
|
||||
} else if !input
|
||||
.chars()
|
||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
||||
{
|
||||
input.to_string()
|
||||
} else {
|
||||
let escaped = input.replace('\'', "'\\''");
|
||||
format!("'{}'", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
paths
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|p| p.exists())
|
||||
.map(|p| normalize_path(p))
|
||||
}
|
||||
|
||||
fn normalize_path(path: PathBuf) -> String {
|
||||
if let Ok(clean) = path.canonicalize() {
|
||||
clean.to_string_lossy().to_string()
|
||||
} else {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
79
packages/tauri-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use serde_json::json;
|
||||
use tauri::menu::Menu;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||
state.manager.status()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
build_menu(&app.handle())?;
|
||||
let dev_mode = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
|
||||
let _ = app_handle.emit(
|
||||
"cli:error",
|
||||
json!({"message": err.to_string()}),
|
||||
);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status])
|
||||
.on_menu_event(|_app_handle, _event| {
|
||||
// No menu items defined currently
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
|
||||
let menu = Menu::new(app)?;
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
49
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CodeNomad",
|
||||
"url": "loading.html",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"theme": "Dark",
|
||||
"backgroundColor": "#1a1a1a"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"resources": [
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
}
|
||||
}
|
||||
3
packages/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
33
packages/ui/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# CodeNomad UI
|
||||
|
||||
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||
## Overview
|
||||
|
||||
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
|
||||
|
||||
## Features
|
||||
|
||||
- **SolidJS**: Fine-grained reactivity for high performance.
|
||||
- **Tailwind CSS**: Utility-first styling for rapid development.
|
||||
- **Vite**: Fast build tool and dev server.
|
||||
|
||||
## Development
|
||||
|
||||
To run the UI in standalone mode (connected to a running server):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server at `http://localhost:3000`.
|
||||
|
||||
## Building
|
||||
|
||||
To build the production assets:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||
32
packages/ui/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.0.68",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||