Compare commits
312 Commits
codenomad/
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68551f6731 | ||
|
|
662a6b94b0 | ||
|
|
77df40169a | ||
|
|
3b411e2e73 | ||
|
|
016c7bda4a | ||
|
|
04fc28c492 | ||
|
|
623a09fd7e | ||
|
|
b00aa7ef84 | ||
|
|
acfa265595 | ||
|
|
35b171764e | ||
|
|
6b53ab2d73 | ||
|
|
1b829094ef | ||
|
|
e28e9f5879 | ||
|
|
cb84547c88 | ||
|
|
e022a158eb | ||
|
|
9d9a6a79ec | ||
|
|
82a7c95dba | ||
|
|
313a0e579e | ||
|
|
a795869064 | ||
|
|
9bf4d351de | ||
|
|
657e78da6a | ||
|
|
dee356558f | ||
|
|
03ed3d3b2c | ||
|
|
a111de1af8 | ||
|
|
8a3b162be9 | ||
|
|
c62cb3ce4a | ||
|
|
d9811e735d | ||
|
|
1ce58b9dd9 | ||
|
|
1907a4da03 | ||
|
|
abf4c67fcc | ||
|
|
bc130ceb5b | ||
|
|
8505a43b16 | ||
|
|
2a3329b5ed | ||
|
|
c9c1cf21f0 | ||
|
|
c7d4f99e48 | ||
|
|
d50c00afb4 | ||
|
|
0ef57df3bc | ||
|
|
0739ec857c | ||
|
|
b060ab45ff | ||
|
|
af6429162f | ||
|
|
2e9ee2cde6 | ||
|
|
d45c0b9367 | ||
|
|
197898c01c | ||
|
|
0c0cfd2d22 | ||
|
|
5107ac207e | ||
|
|
1130066a33 | ||
|
|
403a3ff189 | ||
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 | ||
|
|
d0a0325d7e | ||
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
0af79002ed | ||
|
|
f3981a1cce | ||
|
|
031e8d5717 | ||
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b | ||
|
|
37b3f85e61 | ||
|
|
55a6479c0e | ||
|
|
f88064af06 | ||
|
|
27bccb8d6b | ||
|
|
1b4eff9419 | ||
|
|
6c1febf50e | ||
|
|
75622ef366 | ||
|
|
864f913e3e | ||
|
|
b7d4f8f869 | ||
|
|
0dc5867fb3 | ||
|
|
d13ecba322 | ||
|
|
740f37db86 | ||
|
|
d447b05821 | ||
|
|
1233121a13 | ||
|
|
a950d47df0 | ||
|
|
1c68f5d288 | ||
|
|
3bad0afd7d | ||
|
|
8567d49178 | ||
|
|
09284ee2ce | ||
|
|
a2e30f1b54 | ||
|
|
a4af811de3 | ||
|
|
c5aa59ca75 | ||
|
|
b8e0714b68 | ||
|
|
3f890e5de1 | ||
|
|
935926d875 | ||
|
|
74f753abf4 | ||
|
|
d15340a4b8 | ||
|
|
108cad82d0 | ||
|
|
823dd2d687 | ||
|
|
313e82880b | ||
|
|
68407a01a4 | ||
|
|
0283493f2a | ||
|
|
e989795de3 | ||
|
|
103d2bf1a8 | ||
|
|
0ce7a47e03 | ||
|
|
5df8809c82 | ||
|
|
6e22614648 | ||
|
|
5d87e1e563 | ||
|
|
d735b189f5 | ||
|
|
3d575f4f68 | ||
|
|
b58728dc0e | ||
|
|
672177f570 | ||
|
|
6961efde0b | ||
|
|
b3e0233f4b | ||
|
|
fcebcb0174 | ||
|
|
eaab5e2e9f | ||
|
|
b12825f923 | ||
|
|
8245f474b8 | ||
|
|
3a15b311a8 | ||
|
|
6cb6c0af32 | ||
|
|
7f631611fd | ||
|
|
9d91ecc649 | ||
|
|
87afb06d34 | ||
|
|
4402d9afb0 | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
7c3f808d69 | ||
|
|
a59e929b12 | ||
|
|
8ff4019839 | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 | ||
|
|
6a4e548d2c | ||
|
|
201988b97c | ||
|
|
ad943b2bd4 | ||
|
|
6dac8a6209 | ||
|
|
bec1af6523 | ||
|
|
1719802c0f | ||
|
|
3719dcecf8 | ||
|
|
3dae143830 | ||
|
|
f050273a8e | ||
|
|
8f955cf21c | ||
|
|
a893fca66e | ||
|
|
4f8aba5658 | ||
|
|
219e012c1b | ||
|
|
17716a730b | ||
|
|
c57170d122 | ||
|
|
24c1b7e8ad | ||
|
|
3c76f9776c | ||
|
|
80a02b68b9 | ||
|
|
c766b5ab62 | ||
|
|
133e937772 | ||
|
|
95df743339 | ||
|
|
cd6266757d | ||
|
|
ec0bffe0c2 | ||
|
|
ed322a16bf | ||
|
|
044e46cd6b | ||
|
|
38f75ab06d | ||
|
|
b6bf58ea8f | ||
|
|
2c27fc53ad | ||
|
|
4c5acefa07 | ||
|
|
224cab6a42 | ||
|
|
48b2d7c5ee | ||
|
|
594809538d | ||
|
|
13802537b4 | ||
|
|
ca2b3c232f | ||
|
|
c51e71c7a2 | ||
|
|
482313f662 | ||
|
|
9a4d378238 | ||
|
|
5d5fbfb5f2 | ||
|
|
d147ad49ff | ||
|
|
9b435e3621 | ||
|
|
ab9e188b02 | ||
|
|
2991de528a | ||
|
|
f1bd681618 | ||
|
|
b91dbb1a60 | ||
|
|
688b127c6d | ||
|
|
0f9c99e3bd | ||
|
|
1122070b9c | ||
|
|
57b81f00f8 | ||
|
|
362105fe78 | ||
|
|
5834d2df1b | ||
|
|
ef4c8ef425 | ||
|
|
5f755a7e1c | ||
|
|
8607fab5b5 | ||
|
|
0368fe8248 | ||
|
|
b970281fa7 | ||
|
|
8e5a7fc213 | ||
|
|
15f362e8b5 | ||
|
|
7bbd0a1787 | ||
|
|
f8aae56728 | ||
|
|
027d7fc97d | ||
|
|
e90aef4b3c | ||
|
|
e4e89008b2 | ||
|
|
90baefbb7e | ||
|
|
1c138f4489 | ||
|
|
d36e568ed0 | ||
|
|
d6462ef524 | ||
|
|
6a6fcff2c8 | ||
|
|
a06884ebce | ||
|
|
62bd88f6a4 | ||
|
|
6479561779 | ||
|
|
635237c258 | ||
|
|
33f0aa5714 | ||
|
|
7ca6285d58 | ||
|
|
14c60fef6c | ||
|
|
336de6a19e | ||
|
|
377c8e2249 | ||
|
|
697dea21f8 | ||
|
|
34d3f803d5 | ||
|
|
f824a063a5 | ||
|
|
96fe1b86dd | ||
|
|
5fabf286e8 | ||
|
|
e8947d61b1 | ||
|
|
1ccd14eae8 | ||
|
|
b162764ccb | ||
|
|
2124e540aa | ||
|
|
b5790998b7 | ||
|
|
9800afb785 | ||
|
|
3b73d9d5b9 | ||
|
|
f7ac30afe3 | ||
|
|
ce370d5100 | ||
|
|
c639e535b5 | ||
|
|
e84adebe61 | ||
|
|
d45a1ff078 | ||
|
|
b4121696bb | ||
|
|
f75c942162 | ||
|
|
127a1f628d | ||
|
|
859312ba3b | ||
|
|
4eaa711f01 | ||
|
|
c8ff858565 | ||
|
|
6de6ef5a4a | ||
|
|
4dee154490 | ||
|
|
ef388adc4f | ||
|
|
e8cfad1266 | ||
|
|
3f82dd21fe | ||
|
|
dc13d9a7d0 | ||
|
|
29557fba6d | ||
|
|
dea5079713 | ||
|
|
ddc58a2c3c | ||
|
|
eafd4d83af | ||
|
|
1a0734c6b1 | ||
|
|
f29f197b9a | ||
|
|
e16c5752ed | ||
|
|
375f92410e | ||
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
eb6701185b | ||
|
|
d948ad8e35 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
f3b9ee4e04 | ||
|
|
309a123c1f | ||
|
|
761e3d4268 | ||
|
|
265d497ef4 | ||
|
|
56a052086f | ||
|
|
9a4d205d97 | ||
|
|
5067db3dd0 | ||
|
|
1ef01da019 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
246
.github/workflows/build-and-upload.yml
vendored
@@ -3,6 +3,11 @@ name: Build and Upload Binaries
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
description: "Version to apply to workspace packages (release builds)"
|
description: "Version to apply to workspace packages (release builds)"
|
||||||
required: false
|
required: false
|
||||||
@@ -23,6 +28,21 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
|
upload_actions_artifacts:
|
||||||
|
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
actions_artifacts_retention_days:
|
||||||
|
description: "Retention (days) for GitHub Actions artifacts"
|
||||||
|
required: false
|
||||||
|
default: 7
|
||||||
|
type: number
|
||||||
|
actions_artifacts_name_prefix:
|
||||||
|
description: "Optional prefix for Actions artifact names"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
set_versions:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -45,6 +65,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -54,7 +76,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Set workspace versions
|
- name: Set workspace versions
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
shell: bash
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: 5
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
@@ -65,17 +101,132 @@ jobs:
|
|||||||
- name: Build macOS binaries (Electron)
|
- name: Build macOS binaries (Electron)
|
||||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
release_root="packages/electron-app/release"
|
||||||
|
apps=()
|
||||||
|
while IFS= read -r -d '' app; do
|
||||||
|
apps+=("$app")
|
||||||
|
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||||
|
|
||||||
|
if [ "${#apps[@]}" -eq 0 ]; then
|
||||||
|
echo "No CodeNomad.app found under $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub macOS runners typically have no signing identity. Without any signature,
|
||||||
|
# the shipped .app can fail Gatekeeper with:
|
||||||
|
# code has no resources but signature indicates they must be present
|
||||||
|
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
||||||
|
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
||||||
|
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
||||||
|
for app in "${apps[@]}"; do
|
||||||
|
echo "codesign (adhoc): $app"
|
||||||
|
codesign --force --deep --sign - "$app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Repackage Electron macOS zips (ditto)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prefer the workflow-provided version; fall back to package.json.
|
||||||
|
VERSION_TO_USE="${VERSION:-}"
|
||||||
|
if [ -z "$VERSION_TO_USE" ]; then
|
||||||
|
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_root="packages/electron-app/release"
|
||||||
|
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
||||||
|
# Use find to locate built app bundles instead of ** globs.
|
||||||
|
apps=()
|
||||||
|
while IFS= read -r -d '' app; do
|
||||||
|
apps+=("$app")
|
||||||
|
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||||
|
if [ "${#apps[@]}" -eq 0 ]; then
|
||||||
|
echo "No CodeNomad.app found under $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for app in "${apps[@]}"; do
|
||||||
|
bundle_dir=$(basename "$(dirname "$app")")
|
||||||
|
arch="x64"
|
||||||
|
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
||||||
|
arch="arm64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
||||||
|
rm -f "$out_zip"
|
||||||
|
echo "ditto -ck: $app -> $out_zip"
|
||||||
|
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Validate Electron macOS codesign (unzipped)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
||||||
|
if [ "${#zips[@]}" -eq 0 ]; then
|
||||||
|
echo "No Electron macOS zip artifacts found to validate" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for zip in "${zips[@]}"; do
|
||||||
|
echo "Validating codesign for: $zip"
|
||||||
|
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
||||||
|
mkdir -p "$extract_dir"
|
||||||
|
|
||||||
|
# Use ditto for extraction as well to preserve bundle metadata.
|
||||||
|
ditto -x -k "$zip" "$extract_dir"
|
||||||
|
|
||||||
|
app_path=""
|
||||||
|
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
||||||
|
if [ -d "$candidate" ]; then
|
||||||
|
app_path="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$app_path" ]; then
|
||||||
|
echo "No .app found after extracting $zip" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for file in packages/electron-app/release/*.zip; do
|
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron macOS)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -85,6 +236,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -115,6 +268,15 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Windows)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -124,6 +286,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -149,12 +313,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for file in packages/electron-app/release/*.zip; do
|
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Linux)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||||
|
path: |
|
||||||
|
packages/electron-app/release/*.zip
|
||||||
|
packages/electron-app/release/*.AppImage
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-tauri-macos:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -164,6 +339,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -206,7 +383,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -217,6 +394,15 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri macOS)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -237,6 +423,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -279,7 +467,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -290,6 +478,15 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -310,6 +507,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -355,7 +554,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -368,6 +567,15 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri Windows)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -388,6 +596,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -443,7 +653,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -469,6 +679,15 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri Linux)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||||
|
path: packages/tauri-app/release-tauri/*
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -490,6 +709,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -587,6 +808,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -623,3 +846,12 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||||
|
path: packages/electron-app/release/*.rpm
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
122
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
name: Comment PR Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
RETENTION_DAYS: 7
|
||||||
|
steps:
|
||||||
|
- name: Check PR authorization
|
||||||
|
id: auth
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$BASE_REF" = "dev" ]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Wait for PR build and comment
|
||||||
|
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const prNumber = Number(process.env.PR_NUMBER);
|
||||||
|
const headSha = process.env.HEAD_SHA;
|
||||||
|
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
||||||
|
const marker = '<!-- codenomad-pr-artifacts -->';
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
let matchedRun = null;
|
||||||
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||||
|
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
workflow_id: 'pr-build.yml',
|
||||||
|
event: 'pull_request',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchingRuns = runs
|
||||||
|
.filter((run) => run.head_sha === headSha)
|
||||||
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
|
matchedRun = matchingRuns[0] || null;
|
||||||
|
if (matchedRun && matchedRun.status === 'completed') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
||||||
|
await sleep(10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRun) {
|
||||||
|
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRun.status !== 'completed') {
|
||||||
|
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifacts = await github.paginate(
|
||||||
|
github.rest.actions.listWorkflowRunArtifacts,
|
||||||
|
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
||||||
|
);
|
||||||
|
const active = artifacts.filter((artifact) => !artifact.expired);
|
||||||
|
|
||||||
|
const runUrl = matchedRun.html_url;
|
||||||
|
const artifactsBlock = active.length
|
||||||
|
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
||||||
|
: 'Artifacts: (none found on this run)';
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
marker,
|
||||||
|
'PR builds are available as GitHub Actions artifacts:',
|
||||||
|
'',
|
||||||
|
runUrl,
|
||||||
|
'',
|
||||||
|
`Artifacts expire in ${retentionDays} days.`,
|
||||||
|
artifactsBlock,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const created = await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
||||||
61
.github/workflows/dev-release.yml
vendored
@@ -1,12 +1,13 @@
|
|||||||
name: Develop Pre-Release
|
name: Develop Pre-Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
schedule:
|
||||||
branches:
|
# Nightly build of dev (only if dev has new commits)
|
||||||
- dev
|
- cron: "0 1 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -15,25 +16,63 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
gate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
run: ${{ steps.gate.outputs.run }}
|
||||||
|
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
||||||
|
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Compute version suffix
|
- name: Decide whether to run
|
||||||
id: vars
|
id: gate
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SHA8="${GITHUB_SHA::8}"
|
|
||||||
|
api() {
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
||||||
|
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
||||||
|
echo "Failed to resolve dev head SHA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
DATE=$(date -u +%Y%m%d)
|
DATE=$(date -u +%Y%m%d)
|
||||||
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
SHA8="${DEV_SHA::8}"
|
||||||
|
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
||||||
|
|
||||||
|
SHOULD_RUN="false"
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||||
|
SHOULD_RUN="true"
|
||||||
|
else
|
||||||
|
# Nightly: only run if dev has advanced since last successful dev-release build.
|
||||||
|
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
||||||
|
if [ -z "${LAST_SHA}" ]; then
|
||||||
|
SHOULD_RUN="true"
|
||||||
|
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
||||||
|
SHOULD_RUN="true"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
prerelease:
|
prerelease:
|
||||||
needs: prepare
|
needs: gate
|
||||||
|
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||||
|
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
|||||||
6
.github/workflows/manual-npm-publish.yml
vendored
@@ -19,6 +19,10 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -46,6 +50,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
58
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: PR Build Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-build-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
authorize:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
allowed: ${{ steps.auth.outputs.allowed }}
|
||||||
|
env:
|
||||||
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
steps:
|
||||||
|
- name: Check PR authorization
|
||||||
|
id: auth
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$BASE_REF" = "dev" ]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: authorize
|
||||||
|
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||||
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
upload: false
|
||||||
|
upload_actions_artifacts: true
|
||||||
|
actions_artifacts_retention_days: 7
|
||||||
|
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
||||||
|
set_versions: false
|
||||||
10
.github/workflows/release-ui.yml
vendored
@@ -1,7 +1,13 @@
|
|||||||
name: Release UI
|
name: Release UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call: {}
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -18,6 +24,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
55
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Restrict Non-Dev PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
restrict-non-dev-prs:
|
||||||
|
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
steps:
|
||||||
|
- name: Check allowed actor
|
||||||
|
id: auth
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
|
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Comment on unauthorized PR
|
||||||
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
||||||
|
|
||||||
|
- name: Close unauthorized PR
|
||||||
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh pr close "$PR_NUMBER"
|
||||||
|
|
||||||
|
- name: Fail unauthorized PR
|
||||||
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
|
run: |
|
||||||
|
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||||
|
exit 1
|
||||||
11
.github/workflows/reusable-release.yml
vendored
@@ -3,6 +3,11 @@ name: Reusable Release
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Suffix appended to package.json version"
|
description: "Suffix appended to package.json version"
|
||||||
required: false
|
required: false
|
||||||
@@ -46,6 +51,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -84,6 +91,7 @@ jobs:
|
|||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
@@ -95,6 +103,8 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
uses: ./.github/workflows/release-ui.yml
|
uses: ./.github/workflows/release-ui.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
@@ -103,6 +113,7 @@ jobs:
|
|||||||
- build-and-upload
|
- build-and-upload
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
dist_tag: ${{ inputs.dist_tag }}
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
package_name: ${{ inputs.npm_package_name }}
|
package_name: ${{ inputs.npm_package_name }}
|
||||||
|
|||||||
225
README.md
@@ -1,125 +1,182 @@
|
|||||||
# CodeNomad
|
# CodeNomad
|
||||||
|
|
||||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
## The AI Coding Cockpit for OpenCode
|
||||||
|
|
||||||
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 transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
|
||||||
|
|
||||||
|
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
|
||||||
|
|
||||||

|

|
||||||
_Manage multiple OpenCode sessions side-by-side._
|
|
||||||
|
|
||||||
<details>
|
---
|
||||||
<summary>📸 More Screenshots</summary>
|
|
||||||
|
|
||||||

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

|
- **🚀 Multi-Instance Workspace**
|
||||||
_Rich media previews for images and assets._
|
- **🌐 Remote Access**
|
||||||
|
- **🧠 Session Management**
|
||||||
|
- **🎙️ Voice Input & Speech**
|
||||||
|
- **🌳 Git Worktrees**
|
||||||
|
- **💬 Rich Message Experience**
|
||||||
|
- **🧩 SideCars**
|
||||||
|
- **⌨️ Command Palette**
|
||||||
|
- **📁 File System Browser**
|
||||||
|
- **🔐 Authentication & Security**
|
||||||
|
- **🔔 Notifications**
|
||||||
|
- **🎨 Theming**
|
||||||
|
- **🌍 Internationalization**
|
||||||
|
|
||||||

|
---
|
||||||
_Browser support via CodeNomad Server._
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Choose the way that fits your workflow:
|
### 🖥️ Desktop App
|
||||||
|
|
||||||
### 🖥️ Desktop App (Recommended)
|
Available as both Electron and Tauri builds — choose based on your preference.
|
||||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
|
||||||
|
|
||||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||||
- **Run**: Install and launch like any other app.
|
|
||||||
|
|
||||||
### 🦀 Tauri App (Experimental)
|
| Platform | Formats |
|
||||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
|----------|---------|
|
||||||
|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||||
|
|
||||||
### 💻 CodeNomad Server
|
### 💻 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.
|
|
||||||
|
Run as a local server and access via browser. Perfect for remote development.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||||
- [packages/server/README.md](packages/server/README.md)
|
|
||||||
|
|
||||||
To see all available options:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🧪 Dev Releases
|
### 🧪 Dev Releases
|
||||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
|
||||||
|
Bleeding-edge builds from the `dev` branch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
## Highlights
|
---
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
## SideCars
|
||||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
|
||||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
SideCars let you open local web tools inside CodeNomad as tabs.
|
||||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Configuration</strong></summary>
|
||||||
|
|
||||||
|
- **Name**: Display name used in CodeNomad
|
||||||
|
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
|
||||||
|
- **Base path**: Mounted under `/sidecars/:id`
|
||||||
|
- **Prefix mode**:
|
||||||
|
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
|
||||||
|
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
|
||||||
|
|
||||||
|
Run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
|
||||||
|
```
|
||||||
|
|
||||||
|
Add SideCar as:
|
||||||
|
|
||||||
|
- **Name**: `VSCode`
|
||||||
|
- **Port**: `http://127.0.0.1:8000`
|
||||||
|
- **Base path**: `/sidecars/vscode`
|
||||||
|
- **Prefix mode**: `Preserve prefix`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Terminal (ttyd)</strong></summary>
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ttyd --writable zsh
|
||||||
|
```
|
||||||
|
|
||||||
|
Add SideCar as:
|
||||||
|
|
||||||
|
- **Name**: `Terminal`
|
||||||
|
- **Port**: `http://127.0.0.1:7681`
|
||||||
|
- **Base path**: `/sidecars/terminal`
|
||||||
|
- **Prefix mode**: `Strip prefix`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
- **Node.js 18+** — for server mode or building from source
|
||||||
|
|
||||||
## Troubleshooting
|
---
|
||||||
|
|
||||||
### macOS says the app is damaged
|
## Development
|
||||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
|
||||||
|
|
||||||
```bash
|
CodeNomad is a monorepo built with:
|
||||||
xattr -l /Applications/CodeNomad.app
|
|
||||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
|
||||||
```
|
|
||||||
|
|
||||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
|
||||||
|
|
||||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
|
||||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
|
||||||
|
|
||||||
Try running with one of these environment variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Most reliable workaround (can reduce rendering performance)
|
|
||||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
|
||||||
|
|
||||||
# Alternative for some Wayland setups
|
|
||||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
|
||||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
|
||||||
```
|
|
||||||
|
|
||||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
|
||||||
|
|
||||||
## Architecture & Development
|
|
||||||
|
|
||||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
|
||||||
|
|
||||||
| Package | Description |
|
| 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)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||||
| **[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)** | SolidJS frontend — reactive, fast, beautiful |
|
||||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
|
||||||
|
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
|
||||||
|
|
||||||
### Quick Build
|
### Quick Start
|
||||||
To build the Desktop App from source:
|
|
||||||
|
|
||||||
1. Clone the repo.
|
```bash
|
||||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
cd CodeNomad
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
|
||||||
|
|
||||||
|
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||||
|
```
|
||||||
|
|
||||||
|
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
|
||||||
|
|
||||||
|
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||||
|
```
|
||||||
|
|
||||||
|
See full workaround in the original README.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 845 KiB |
|
Before Width: | Height: | Size: 835 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
417
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "4.52.5",
|
||||||
|
"@rollup/rollup-darwin-x64": "4.52.5",
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "4.52.5",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.52.5"
|
||||||
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/server",
|
"packages/server",
|
||||||
@@ -2809,9 +2817,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.1.11",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -2931,16 +2939,304 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.52.5",
|
"version": "4.52.5",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
|
"version": "4.52.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
|
||||||
|
"integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/core": {
|
"node_modules/@shikijs/core": {
|
||||||
@@ -3253,9 +3549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.9.1",
|
"version": "2.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3305,6 +3601,32 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -8214,6 +8536,27 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"regex-recursion": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "6.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
||||||
|
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -10218,14 +10561,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/tauri-plugin-keepawake-api": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -10966,6 +11301,36 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/virtua": {
|
||||||
|
"version": "0.48.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
||||||
|
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.14.0",
|
||||||
|
"react-dom": ">=16.14.0",
|
||||||
|
"solid-js": ">=1.0",
|
||||||
|
"svelte": ">=5.0",
|
||||||
|
"vue": ">=3.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11971,6 +12336,7 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -11985,7 +12351,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -11995,6 +12361,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
@@ -12021,7 +12388,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12031,6 +12398,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
@@ -12062,7 +12430,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12070,16 +12438,18 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.11",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
@@ -12092,7 +12462,8 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"tauri-plugin-keepawake-api": "^0.1.0"
|
"virtua": "^0.48.8",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"build:binaries": "npm run build:binaries --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",
|
"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"
|
"bumpVersion": "node ./scripts/bump-version.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -30,5 +30,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "4.52.5",
|
||||||
|
"@rollup/rollup-darwin-x64": "4.52.5",
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "4.52.5",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.52.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.10.3",
|
"minServerVersion": "0.14.0",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ export interface Env {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
if (url.pathname === "/version.json") {
|
||||||
|
const response = await env.ASSETS.fetch(request)
|
||||||
|
|
||||||
|
const newHeaders = new Headers(response.headers)
|
||||||
|
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||||
|
newHeaders.set("Pragma", "no-cache")
|
||||||
|
newHeaders.set("Expires", "0")
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return env.ASSETS.fetch(request)
|
return env.ASSETS.fetch(request)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
.vite/
|
||||||
|
electron/resources/server/
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
|
import fs from "fs"
|
||||||
|
import { requestMicrophoneAccess } from "./permissions"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -65,6 +67,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||||
|
if (!Array.isArray(paths)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = paths.filter((value): value is string => {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.statSync(value).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return directories
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
const next = Boolean(enabled)
|
const next = Boolean(enabled)
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -92,6 +112,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"media:requestMicrophoneAccess",
|
||||||
|
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"remote:openWindow",
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||||
|
): Promise<{ ok: boolean }> => {
|
||||||
|
const opener = (mainWindow as BrowserWindow & {
|
||||||
|
__codenomadOpenRemoteWindow?: (payload: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
}) => Promise<void>
|
||||||
|
}).__codenomadOpenRemoteWindow
|
||||||
|
if (!opener) {
|
||||||
|
throw new Error("Remote window opening is not available")
|
||||||
|
}
|
||||||
|
await opener(payload)
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, mkdirSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupCliIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
|
import { configureMediaPermissionHandlers } from "./permissions"
|
||||||
import { CliProcessManager } from "./process-manager"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
@@ -13,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
|||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
function configureDevStoragePaths() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = "CodeNomad"
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.setName(appName)
|
||||||
|
|
||||||
|
const userDataPath = join(app.getPath("appData"), appName)
|
||||||
|
const sessionDataPath = join(userDataPath, "session-data")
|
||||||
|
|
||||||
|
mkdirSync(userDataPath, { recursive: true })
|
||||||
|
mkdirSync(sessionDataPath, { recursive: true })
|
||||||
|
|
||||||
|
app.setPath("userData", userDataPath)
|
||||||
|
app.setPath("sessionData", sessionDataPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to configure dev storage paths", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureDevStoragePaths()
|
||||||
|
|
||||||
const cliManager = new CliProcessManager()
|
const cliManager = new CliProcessManager()
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
@@ -20,6 +46,8 @@ let pendingCliUrl: string | null = null
|
|||||||
let pendingBootstrapToken: string | null = null
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
app.commandLine.appendSwitch("disable-spell-checking")
|
||||||
@@ -92,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedRendererOrigins(): string[] {
|
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||||
const origins = new Set<string>()
|
const origins = new Set<string>()
|
||||||
|
if (window) {
|
||||||
|
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||||
|
origins.add(origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||||
for (const candidate of rendererCandidates) {
|
for (const candidate of rendererCandidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -108,13 +141,13 @@ function getAllowedRendererOrigins(): string[] {
|
|||||||
return Array.from(origins)
|
return Array.from(origins)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldOpenExternally(url: string): boolean {
|
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const allowedOrigins = getAllowedRendererOrigins()
|
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||||
return !allowedOrigins.includes(parsed.origin)
|
return !allowedOrigins.includes(parsed.origin)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -127,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
@@ -135,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store allowed origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||||
|
remoteWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(url).origin
|
||||||
|
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to store insecure origin", url, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||||
|
insecureWindowOrigins.delete(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsecureOriginAllowed(url: string) {
|
||||||
|
try {
|
||||||
|
const targetOrigin = new URL(url).origin
|
||||||
|
for (const origins of insecureWindowOrigins.values()) {
|
||||||
|
if (origins.has(targetOrigin)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let cachedPreloadPath: string | null = null
|
let cachedPreloadPath: string | null = null
|
||||||
function getPreloadPath() {
|
function getPreloadPath() {
|
||||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||||
@@ -203,28 +277,34 @@ function createWindow() {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
|
additionalArguments: ["--codenomad-window-context=local"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setupNavigationGuards(mainWindow)
|
const window = mainWindow
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
loadLoadingScreen(mainWindow)
|
clearWindowAllowedOrigin(window)
|
||||||
|
loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
}
|
}
|
||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(window)
|
||||||
setupCliIPC(mainWindow, cliManager)
|
setupCliIPC(window, cliManager)
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
window.on("closed", () => {
|
||||||
destroyPreloadingView()
|
destroyPreloadingView()
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
@@ -321,13 +401,69 @@ function finalizeCliSwap(url: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const window = mainWindow
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
currentCliUrl = url
|
currentCliUrl = url
|
||||||
|
setWindowAllowedOrigin(window, url)
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl)
|
||||||
|
return `${name} - ${parsed.host}`
|
||||||
|
} catch {
|
||||||
|
return `${name} - ${baseUrl}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||||
|
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||||
|
const targetUrl = new URL(payload.baseUrl)
|
||||||
|
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
icon: getIconPath(),
|
||||||
|
title,
|
||||||
|
webPreferences: {
|
||||||
|
preload: getPreloadPath(),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
spellcheck: !isMac,
|
||||||
|
additionalArguments: ["--codenomad-window-context=remote"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||||
|
if (payload.skipTlsVerify) {
|
||||||
|
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
window.on("closed", () => {
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.loadURL(targetUrl.toString())
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
|
||||||
let bootstrapExchangeInFlight = false
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
@@ -350,6 +486,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const sessionCookieName = cliManager.getAuthCookieName()
|
||||||
const target = new URL("/api/auth/token", baseUrl)
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
const body = JSON.stringify({ token })
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
@@ -380,14 +517,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.defaultSession.cookies.set({
|
await session.defaultSession.cookies.set({
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
name: SESSION_COOKIE_NAME,
|
name: sessionCookieName,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -489,6 +626,7 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
|
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
})
|
})
|
||||||
@@ -502,6 +640,17 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
|
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||||
|
if (isInsecureOriginAllowed(url)) {
|
||||||
|
event.preventDefault()
|
||||||
|
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { session, systemPreferences } from "electron"
|
||||||
|
|
||||||
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
||||||
|
if (!origin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = new URL(origin).origin
|
||||||
|
return allowedOrigins.includes(normalized)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||||
|
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||||
|
if (permission !== "media") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
||||||
|
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
||||||
|
})
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
||||||
|
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestMicrophoneAccess(): Promise<boolean> {
|
||||||
|
if (!isMac) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = systemPreferences.getMediaAccessStatus("microphone")
|
||||||
|
if (status === "granted") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemPreferences.askForMediaAccess("microphone")
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app } from "electron"
|
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -38,6 +42,9 @@ interface CliEntryResolution {
|
|||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedChild = ChildProcess | UtilityProcess
|
||||||
|
type ChildLaunchMode = "spawn" | "utility"
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
@@ -97,7 +104,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
return "local"
|
return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = parsed?.preferences?.listeningMode
|
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
@@ -117,11 +124,13 @@ export declare interface CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ChildProcess
|
private child?: ManagedChild
|
||||||
|
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
|
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
private requestedStop = false
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
@@ -132,36 +141,67 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
|
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
|
||||||
console.info(
|
let child: ManagedChild
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
const runtimePath = this.resolveShellNodeCommand()
|
||||||
|
const entryPath = this.resolveBundledProdEntry()
|
||||||
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
|
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
|
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||||
|
const supervisorPayload = JSON.stringify({
|
||||||
|
command: shellCommand.command,
|
||||||
|
args: shellCommand.args,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
console.info(
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
)
|
||||||
|
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||||
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
env: shellEnv,
|
||||||
cwd: process.cwd(),
|
stdio: "pipe",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
env,
|
})
|
||||||
shell: false,
|
this.childLaunchMode = "utility"
|
||||||
detached,
|
} else {
|
||||||
})
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
console.info(
|
||||||
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
|
)
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
if (!child.pid) {
|
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 detached = process.platform !== "win32"
|
||||||
|
child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
detached,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
|
this.childLaunchMode = "spawn"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "spawn" && !child.pid) {
|
||||||
console.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,23 +216,48 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("error", (error) => {
|
if (this.childLaunchMode === "utility") {
|
||||||
console.error("[cli] failed to start CLI:", error)
|
const utilityChild = child as UtilityProcess
|
||||||
this.updateStatus({ state: "error", error: error.message })
|
|
||||||
this.emit("error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
utilityChild.on("error", (error) => {
|
||||||
const failed = this.status.state !== "ready"
|
const message = this.describeUtilityProcessError(error)
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
console.error("[cli] utility supervisor failed:", error)
|
||||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
this.updateStatus({ state: "error", error: message })
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
this.emit("error", new Error(message))
|
||||||
if (failed && error) {
|
})
|
||||||
this.emit("error", new Error(error))
|
|
||||||
}
|
utilityChild.on("exit", (code) => {
|
||||||
this.emit("exit", this.status)
|
const failed = this.status.state !== "ready"
|
||||||
this.child = undefined
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
||||||
})
|
console.info(`[cli] exit (code=${code ?? ""})${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
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
|
spawnedChild.on("error", (error) => {
|
||||||
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
spawnedChild.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) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -219,16 +284,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "utility") {
|
||||||
|
return this.stopUtilityChild(child as UtilityProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
const pid = child.pid
|
const pid = spawnedChild.pid
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
try {
|
try {
|
||||||
@@ -304,7 +375,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
sendStopSignal("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
spawnedChild.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
console.info("[cli] CLI process exited")
|
console.info("[cli] CLI process exited")
|
||||||
@@ -324,10 +395,54 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
||||||
|
this.requestedStop = true
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
console.info("[cli] CLI process exited")
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (child.pid === undefined) {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getStatus(): CliStatus {
|
getStatus(): CliStatus {
|
||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAuthCookieName(): string {
|
||||||
|
return this.authCookieName
|
||||||
|
}
|
||||||
|
|
||||||
private resolveListeningMode(): ListeningMode {
|
private resolveListeningMode(): ListeningMode {
|
||||||
return readListeningModeFromConfig()
|
return readListeningModeFromConfig()
|
||||||
}
|
}
|
||||||
@@ -335,14 +450,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
const pid = this.child.pid
|
const pid = this.child.pid
|
||||||
if (pid && process.platform !== "win32") {
|
if (this.childLaunchMode === "utility") {
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pid && process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, "SIGKILL")
|
process.kill(-pid, "SIGKILL")
|
||||||
} catch {
|
} catch {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
@@ -416,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token"]
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
@@ -431,7 +554,9 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||||
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
||||||
|
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
||||||
|
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
@@ -447,6 +572,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildExecutableCommand(command: string, args: string[]): string {
|
||||||
|
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
@@ -517,4 +646,58 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||||
|
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCliSupervisorPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||||
|
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveShellNodeCommand(): string {
|
||||||
|
const configured = process.env.NODE_BINARY?.trim()
|
||||||
|
return configured && configured.length > 0 ? configured : "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBundledProdEntry(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||||
|
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const typed = error as { type?: unknown; location?: unknown }
|
||||||
|
if (typeof typed.type === "string") {
|
||||||
|
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
function resolveWindowContext() {
|
||||||
|
const prefix = "--codenomad-window-context="
|
||||||
|
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
|
||||||
|
const context = arg ? arg.slice(prefix.length) : "local"
|
||||||
|
return context === "remote" ? "remote" : "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeHost(windowContext) {
|
||||||
|
return "electron"
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowContext = resolveWindowContext()
|
||||||
|
|
||||||
|
const localElectronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||||
@@ -12,8 +25,29 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||||
|
getPathForFile: (file) => {
|
||||||
|
try {
|
||||||
|
return webUtils.getPathForFile(file)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
const remoteElectronAPI = {
|
||||||
|
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
|
||||||
|
setWakeLock: localElectronAPI.setWakeLock,
|
||||||
|
showNotification: localElectronAPI.showNotification,
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld(
|
||||||
|
"electronAPI",
|
||||||
|
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
|
||||||
|
)
|
||||||
|
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
|
||||||
|
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))
|
||||||
|
|||||||
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn } = require("child_process")
|
||||||
|
|
||||||
|
const SHUTDOWN_GRACE_MS = 30_000
|
||||||
|
|
||||||
|
let child = null
|
||||||
|
let shutdownTimer = null
|
||||||
|
|
||||||
|
function log(message, error) {
|
||||||
|
if (error) {
|
||||||
|
console.error(`[cli-supervisor] ${message}`, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[cli-supervisor] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShutdownTimer() {
|
||||||
|
if (shutdownTimer) {
|
||||||
|
clearTimeout(shutdownTimer)
|
||||||
|
shutdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardStream(stream, target) {
|
||||||
|
if (!stream) return
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
target.write(chunk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminateChild(force) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill(force ? "SIGKILL" : "SIGTERM")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestShutdown(force = false) {
|
||||||
|
if (!child) {
|
||||||
|
process.exit(force ? 1 : 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminateChild(force)
|
||||||
|
if (force) {
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearShutdownTimer()
|
||||||
|
shutdownTimer = setTimeout(() => {
|
||||||
|
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
||||||
|
terminateChild(true)
|
||||||
|
}, SHUTDOWN_GRACE_MS)
|
||||||
|
shutdownTimer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShutdownHandlers() {
|
||||||
|
process.on("SIGTERM", () => requestShutdown(false))
|
||||||
|
process.on("SIGINT", () => requestShutdown(false))
|
||||||
|
process.on("disconnect", () => requestShutdown(false))
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
log("uncaught exception", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
process.on("unhandledRejection", (error) => {
|
||||||
|
log("unhandled rejection", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload() {
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Supervisor payload is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Supervisor payload must be an object")
|
||||||
|
}
|
||||||
|
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
||||||
|
throw new Error("Supervisor payload command is required")
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
||||||
|
throw new Error("Supervisor payload args must be a string array")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: parsed.command,
|
||||||
|
args: parsed.args,
|
||||||
|
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
installShutdownHandlers()
|
||||||
|
|
||||||
|
const payload = parsePayload()
|
||||||
|
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
||||||
|
|
||||||
|
child = spawn(payload.command, payload.args, {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
|
||||||
|
forwardStream(child.stdout, process.stdout)
|
||||||
|
forwardStream(child.stderr, process.stderr)
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
log("failed to spawn shell command", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearShutdownTimer()
|
||||||
|
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
||||||
|
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
||||||
|
process.exit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -15,8 +15,13 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "npm run dev:info",
|
||||||
|
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
|
||||||
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
|
"prepare:resources": "node scripts/prepare-resources.js",
|
||||||
|
"prebuild": "npm run prepare:resources",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
@@ -30,8 +35,11 @@
|
|||||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||||
"build:all": "node scripts/build.js all",
|
"build:all": "node scripts/build.js all",
|
||||||
|
"prepackage:mac": "npm run prepare:resources",
|
||||||
"package:mac": "electron-builder --mac",
|
"package:mac": "electron-builder --mac",
|
||||||
|
"prepackage:win": "npm run prepare:resources",
|
||||||
"package:win": "electron-builder --win",
|
"package:win": "electron-builder --win",
|
||||||
|
"prepackage:linux": "npm run prepare:resources",
|
||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -42,6 +50,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
@@ -53,7 +62,7 @@
|
|||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "ai.opencode.client",
|
"appId": "ai.neuralnomads.codenomad.client",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
@@ -78,6 +87,12 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"entitlements": "electron/resources/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
||||||
|
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
||||||
|
},
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
@@ -132,6 +147,13 @@
|
|||||||
"x64",
|
"x64",
|
||||||
"arm64"
|
"arm64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
|||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||||
|
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
|
})
|
||||||
|
|
||||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
|
|||||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path, { join } from "path"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
|
const appDir = join(__dirname, "..")
|
||||||
|
const workspaceRoot = join(appDir, "..", "..")
|
||||||
|
const serverRoot = join(appDir, "..", "server")
|
||||||
|
const resourcesRoot = join(appDir, "electron", "resources")
|
||||||
|
const serverDest = join(resourcesRoot, "server")
|
||||||
|
const npmExecPath = process.env.npm_execpath
|
||||||
|
const npmNodeExecPath = process.env.npm_node_execpath
|
||||||
|
|
||||||
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
|
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[prepare-resources] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerBuild() {
|
||||||
|
const distPath = join(serverRoot, "dist")
|
||||||
|
const publicPath = join(serverRoot, "public")
|
||||||
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||||
|
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDependencies() {
|
||||||
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("installing production server dependencies")
|
||||||
|
const npmArgs = [
|
||||||
|
"install",
|
||||||
|
"--omit=dev",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--workspaces=false",
|
||||||
|
"--package-lock=false",
|
||||||
|
"--install-strategy=shallow",
|
||||||
|
"--fund=false",
|
||||||
|
"--audit=false",
|
||||||
|
]
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
npm_config_workspaces: "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||||
|
const result = npmCli
|
||||||
|
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||||
|
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyServerArtifacts() {
|
||||||
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
|
|
||||||
|
for (const name of serverSources) {
|
||||||
|
const from = join(serverRoot, name)
|
||||||
|
const to = join(serverDest, name)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
throw new Error(`Missing required server artifact: ${from}`)
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||||
|
log(`copied ${name} to Electron resources`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = join(serverDest, "node_modules")
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
fs.rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
log(`removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[prepare-resources] failed:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.53"
|
"@opencode-ai/plugin": "1.3.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||||
|
|
||||||
|
let voiceModeEnabled = false
|
||||||
|
|
||||||
export async function CodeNomadPlugin(input: PluginInput) {
|
export async function CodeNomadPlugin(input: PluginInput) {
|
||||||
const config = getCodeNomadConfig()
|
const config = getCodeNomadConfig()
|
||||||
const client = createCodeNomadClient(config)
|
const client = createCodeNomadClient(config)
|
||||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
pingTs: (event.properties as any)?.ts,
|
pingTs: (event.properties as any)?.ts,
|
||||||
},
|
},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "codenomad.voiceMode") {
|
||||||
|
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
tool: {
|
tool: {
|
||||||
...backgroundProcessTools,
|
...backgroundProcessTools,
|
||||||
},
|
},
|
||||||
|
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
|
||||||
|
if (!voiceModeEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
|
||||||
|
},
|
||||||
async event(input: { event: any }) {
|
async event(input: { event: any }) {
|
||||||
const opencodeEvent = input?.event
|
const opencodeEvent = input?.event
|
||||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||||
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVoiceModePrompt(): string {
|
||||||
|
return [
|
||||||
|
"Voice conversation mode is enabled.",
|
||||||
|
"Prepend your reply with a fenced code block using language `spoken`.",
|
||||||
|
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
|
||||||
|
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
|
||||||
|
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
|
||||||
|
"Do not add generic phrases about whether the user should read more.",
|
||||||
|
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
|
||||||
|
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
|
||||||
|
"After the `spoken` block, continue with your normal detailed response.",
|
||||||
|
"Example:",
|
||||||
|
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type BackgroundProcess = {
|
|||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackgroundProcessNotificationRequest = {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
type BackgroundProcessOptions = {
|
type BackgroundProcessOptions = {
|
||||||
baseDir: string
|
baseDir: string
|
||||||
}
|
}
|
||||||
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
|
|||||||
args: {
|
args: {
|
||||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||||
|
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args, context) {
|
||||||
assertCommandWithinBase(args.command, options.baseDir)
|
assertCommandWithinBase(args.command, options.baseDir)
|
||||||
|
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
|
||||||
|
? {
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
directory: context.directory,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
const process = await request<BackgroundProcess>("", {
|
const process = await request<BackgroundProcess>("", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||||
|
|||||||
@@ -5,18 +5,21 @@
|
|||||||
## Features & Capabilities
|
## Features & Capabilities
|
||||||
|
|
||||||
### 🌍 Deployment Freedom
|
### 🌍 Deployment Freedom
|
||||||
|
|
||||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
- **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.
|
- **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.
|
- **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.
|
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||||
|
|
||||||
### ⚡️ Workspace Power
|
### ⚡️ Workspace Power
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
- **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.
|
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||||
- Node.js 18+ and npm (for running or building from source).
|
- Node.js 18+ and npm (for running or building from source).
|
||||||
- A workspace folder on disk you want to serve.
|
- A workspace folder on disk you want to serve.
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Run via npx (Recommended)
|
### Run via npx (Recommended)
|
||||||
|
|
||||||
You can run CodeNomad directly without installing it:
|
You can run CodeNomad directly without installing it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -43,6 +47,7 @@ On startup, CodeNomad prints two URLs:
|
|||||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||||
|
|
||||||
### Install Globally
|
### Install Globally
|
||||||
|
|
||||||
Or install it globally to use the `codenomad` command:
|
Or install it globally to use the `codenomad` command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -51,6 +56,7 @@ codenomad --launch
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Install Locally (per-project)
|
### Install Locally (per-project)
|
||||||
|
|
||||||
If you prefer to install CodeNomad into a project and run the local binary:
|
If you prefer to install CodeNomad into a project and run the local binary:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -61,6 +67,7 @@ npx codenomad --launch
|
|||||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||||
|
|
||||||
### Common Flags
|
### Common Flags
|
||||||
|
|
||||||
You can configure the server using flags or environment variables:
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
| Flag | Env Variable | Description |
|
| Flag | Env Variable | Description |
|
||||||
@@ -74,7 +81,7 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
||||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
@@ -87,10 +94,11 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
|
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
||||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||||
|
|
||||||
### Dev Releases (Advanced)
|
### Dev Releases (Advanced)
|
||||||
|
|
||||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -141,12 +149,14 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||||
|
|
||||||
### Progressive Web App (PWA)
|
### Progressive Web App (PWA)
|
||||||
|
|
||||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||||
|
|
||||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||||
@@ -158,5 +168,6 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
|||||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||||
|
|
||||||
### Data Storage
|
### Data Storage
|
||||||
|
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentModelSelection,
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
ConfigFile,
|
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
OpenCodeBinary,
|
OpenCodeBinary,
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -82,6 +81,55 @@ export interface WorktreeMap {
|
|||||||
parentSessionWorktreeSlug: Record<string, string>
|
parentSessionWorktreeSlug: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
|
||||||
|
|
||||||
|
export interface WorktreeGitStatusEntry {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
stagedStatus: GitChangeKind | null
|
||||||
|
stagedAdditions: number
|
||||||
|
stagedDeletions: number
|
||||||
|
unstagedStatus: GitChangeKind | null
|
||||||
|
unstagedAdditions: number
|
||||||
|
unstagedDeletions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
|
||||||
|
|
||||||
|
export type WorktreeGitDiffScope = "staged" | "unstaged"
|
||||||
|
|
||||||
|
export interface WorktreeGitPathsRequest {
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitMutationResponse {
|
||||||
|
ok: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitCommitRequest {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitCommitResponse {
|
||||||
|
ok: true
|
||||||
|
commitSha?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitDiffResponse {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
before: string
|
||||||
|
after: string
|
||||||
|
isBinary?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitDiffRequest {
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
}
|
||||||
|
|
||||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
@@ -171,6 +219,24 @@ export interface InstanceStreamEvent {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SideCarKind = "port"
|
||||||
|
|
||||||
|
export type SideCarPrefixMode = "strip" | "preserve"
|
||||||
|
|
||||||
|
export type SideCarStatus = "running" | "stopped"
|
||||||
|
|
||||||
|
export interface SideCar {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
status: SideCarStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinaryRecord {
|
export interface BinaryRecord {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -183,9 +249,9 @@ export interface BinaryRecord {
|
|||||||
validationError?: string
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppConfig = ConfigFile
|
export type SettingsOwner = string
|
||||||
export type AppConfigResponse = AppConfig
|
export type SettingsBucket = Record<string, unknown>
|
||||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
export interface BinaryListResponse {
|
export interface BinaryListResponse {
|
||||||
binaries: BinaryRecord[]
|
binaries: BinaryRecord[]
|
||||||
@@ -208,14 +274,89 @@ export interface BinaryValidationResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeechSegment {
|
||||||
|
startMs: number
|
||||||
|
endMs: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechCapabilitiesResponse {
|
||||||
|
available: boolean
|
||||||
|
configured: boolean
|
||||||
|
provider: string
|
||||||
|
supportsStt: boolean
|
||||||
|
supportsTts: boolean
|
||||||
|
supportsStreamingTts: boolean
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
ttsFormats: string[]
|
||||||
|
streamingTtsFormats: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechTranscriptionResponse {
|
||||||
|
text: string
|
||||||
|
language?: string
|
||||||
|
durationMs?: number
|
||||||
|
segments?: SpeechSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechSynthesisResponse {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceModeStateResponse {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
lastConnectedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProbeRequest {
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteServerProbeResponse {
|
||||||
|
ok: boolean
|
||||||
|
reachable: boolean
|
||||||
|
normalizedUrl: string
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
requiresAuth: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
error?: string
|
||||||
|
errorCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateRequest {
|
||||||
|
baseUrl: string
|
||||||
|
skipTlsVerify?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateResponse {
|
||||||
|
sessionId: string
|
||||||
|
windowUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "config.appChanged"
|
| "sidecar.updated"
|
||||||
| "config.binariesChanged"
|
| "sidecar.removed"
|
||||||
|
| "storage.configChanged"
|
||||||
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
@@ -226,8 +367,10 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "config.appChanged"; config: AppConfig }
|
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
| { type: "sidecar.removed"; sidecarId: string }
|
||||||
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
@@ -292,6 +435,8 @@ export interface ServerMeta {
|
|||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|
||||||
|
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
|
||||||
|
|
||||||
export interface BackgroundProcess {
|
export interface BackgroundProcess {
|
||||||
id: string
|
id: string
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -304,6 +449,8 @@ export interface BackgroundProcess {
|
|||||||
stoppedAt?: string
|
stoppedAt?: string
|
||||||
exitCode?: number
|
exitCode?: number
|
||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
|
terminalReason?: BackgroundProcessTerminalReason
|
||||||
|
notifyEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundProcessListResponse {
|
export interface BackgroundProcessListResponse {
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
|||||||
password?: string
|
password?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
|
cookieName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private readonly authStore: AuthStore | null
|
private readonly authStore: AuthStore | null
|
||||||
private readonly tokenManager: TokenManager | null
|
private readonly tokenManager: TokenManager | null
|
||||||
private readonly sessionManager = new SessionManager()
|
private readonly sessionManager = new SessionManager()
|
||||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
private readonly cookieName: string
|
||||||
private readonly authEnabled: boolean
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||||
|
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
@@ -102,13 +104,18 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
return this.getSessionFromHeaders(request.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
// When auth is disabled, treat all requests as authenticated.
|
// When auth is disabled, treat all requests as authenticated.
|
||||||
// We still return a stable username so callers can display it.
|
// We still return a stable username so callers can display it.
|
||||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookies = parseCookies(request.headers.cookie)
|
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
|
||||||
|
const cookies = parseCookies(cookieHeader)
|
||||||
const sessionId = cookies[this.cookieName]
|
const sessionId = cookies[this.cookieName]
|
||||||
const session = this.sessionManager.getSession(sessionId)
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
@@ -139,6 +146,16 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCookieName(value: string | undefined): string {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||||
|
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAuthFilePath(configPath: string) {
|
function resolveAuthFilePath(configPath: string) {
|
||||||
const resolvedConfigPath = resolvePath(configPath)
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
|
|||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { WorkspaceManager } from "../workspaces/manager"
|
import type { WorkspaceManager } from "../workspaces/manager"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
|
||||||
|
|
||||||
const ROOT_DIR = ".codenomad/background_processes"
|
const ROOT_DIR = ".codenomad/background_processes"
|
||||||
const INDEX_FILE = "index.json"
|
const INDEX_FILE = "index.json"
|
||||||
@@ -27,6 +27,31 @@ interface RunningProcess {
|
|||||||
outputPath: string
|
outputPath: string
|
||||||
exitPromise: Promise<void>
|
exitPromise: Promise<void>
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
|
completion?: ProcessCompletion
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessCompletion {
|
||||||
|
reason: BackgroundProcessTerminalReason
|
||||||
|
endContext: "normal" | "workspace_cleanup"
|
||||||
|
removeAfterFinalize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackgroundProcessNotificationState {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
sentAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedBackgroundProcess extends BackgroundProcess {
|
||||||
|
notify?: BackgroundProcessNotificationState
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartOptions {
|
||||||
|
notify?: boolean
|
||||||
|
notification?: {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BackgroundProcessManager {
|
export class BackgroundProcessManager {
|
||||||
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
|
|||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
records.map(async (record) => ({
|
records.map(async (record) => ({
|
||||||
...record,
|
...this.toPublicProcess(record),
|
||||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
return enriched
|
return enriched
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
|
||||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error("Workspace not found")
|
throw new Error("Workspace not found")
|
||||||
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
|
|||||||
this.killProcessTree(child, "SIGTERM")
|
this.killProcessTree(child, "SIGTERM")
|
||||||
})
|
})
|
||||||
|
|
||||||
const record: BackgroundProcess = {
|
const record: PersistedBackgroundProcess = {
|
||||||
|
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
title,
|
||||||
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
|
|||||||
pid: child.pid,
|
pid: child.pid,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
outputSizeBytes: 0,
|
outputSizeBytes: 0,
|
||||||
|
notify: options.notify && options.notification
|
||||||
|
? {
|
||||||
|
sessionID: options.notification.sessionID,
|
||||||
|
directory: options.notification.directory,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningState: RunningProcess = {
|
||||||
|
id,
|
||||||
|
child,
|
||||||
|
outputPath,
|
||||||
|
exitPromise: Promise.resolve(),
|
||||||
|
workspaceId,
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitPromise = new Promise<void>((resolve) => {
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
|
|||||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||||
this.running.delete(id)
|
this.running.delete(id)
|
||||||
|
|
||||||
record.status = this.statusFromExit(code)
|
const completion = runningState.completion ?? this.completionFromExit(code)
|
||||||
|
|
||||||
|
record.terminalReason = completion.reason
|
||||||
|
record.status = this.statusFromReason(completion.reason)
|
||||||
record.exitCode = code === null ? undefined : code
|
record.exitCode = code === null ? undefined : code
|
||||||
record.stoppedAt = new Date().toISOString()
|
record.stoppedAt = new Date().toISOString()
|
||||||
|
|
||||||
await this.upsertIndex(workspaceId, record)
|
await this.finalizeRecord(workspaceId, record, completion)
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
|
||||||
this.publishUpdate(workspaceId, record)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
runningState.exitPromise = exitPromise
|
||||||
|
|
||||||
|
this.running.set(id, runningState)
|
||||||
|
|
||||||
let lastPublishAt = 0
|
let lastPublishAt = 0
|
||||||
const maybePublishSize = () => {
|
const maybePublishSize = () => {
|
||||||
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
|
|||||||
await this.upsertIndex(workspaceId, record)
|
await this.upsertIndex(workspaceId, record)
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||||
this.publishUpdate(workspaceId, record)
|
this.publishUpdate(workspaceId, record)
|
||||||
return record
|
return this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||||
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
|
running.completion = { reason: "user_stopped", endContext: "normal" }
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
|
const updated = await this.findProcess(workspaceId, processId)
|
||||||
|
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.status === "running") {
|
if (record.status === "running") {
|
||||||
record.status = "stopped"
|
record.status = "stopped"
|
||||||
|
record.terminalReason = "user_stopped"
|
||||||
record.stoppedAt = new Date().toISOString()
|
record.stoppedAt = new Date().toISOString()
|
||||||
await this.upsertIndex(workspaceId, record)
|
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
|
||||||
this.publishUpdate(workspaceId, record)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return record
|
return this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||||
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
|
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeFromIndex(workspaceId, processId)
|
record.status = "stopped"
|
||||||
await this.removeProcessDir(workspaceId, processId)
|
record.terminalReason = "user_terminated"
|
||||||
|
record.stoppedAt = new Date().toISOString()
|
||||||
this.deps.eventBus.publish({
|
await this.finalizeRecord(workspaceId, record, {
|
||||||
type: "instance.event",
|
reason: "user_terminated",
|
||||||
instanceId: workspaceId,
|
endContext: "normal",
|
||||||
event: { type: "background.process.removed", properties: { processId } },
|
removeAfterFinalize: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
|
|||||||
private async cleanupWorkspace(workspaceId: string) {
|
private async cleanupWorkspace(workspaceId: string) {
|
||||||
for (const [, running] of this.running.entries()) {
|
for (const [, running] of this.running.entries()) {
|
||||||
if (running.workspaceId !== workspaceId) continue
|
if (running.workspaceId !== workspaceId) continue
|
||||||
|
running.completion = {
|
||||||
|
reason: "user_terminated",
|
||||||
|
endContext: "workspace_cleanup",
|
||||||
|
removeAfterFinalize: true,
|
||||||
|
}
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
private completionFromExit(code: number | null): ProcessCompletion {
|
||||||
if (code === null) return "stopped"
|
if (code === 0) {
|
||||||
if (code === 0) return "stopped"
|
return { reason: "finished", endContext: "normal" }
|
||||||
return "error"
|
}
|
||||||
|
|
||||||
|
return { reason: "failed", endContext: "normal" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
|
||||||
|
if (reason === "failed") return "error"
|
||||||
|
return "stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||||
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
|
|||||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
|
||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
return records.find((entry) => entry.id === processId) ?? null
|
return records.find((entry) => entry.id === processId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
|
||||||
const indexPath = await this.getIndexPath(workspaceId)
|
const indexPath = await this.getIndexPath(workspaceId)
|
||||||
if (!existsSync(indexPath)) return []
|
if (!existsSync(indexPath)) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(indexPath, "utf-8")
|
const raw = await fs.readFile(indexPath, "utf-8")
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
const index = records.findIndex((entry) => entry.id === record.id)
|
const index = records.findIndex((entry) => entry.id === record.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
|
|||||||
await this.writeIndex(workspaceId, next)
|
await this.writeIndex(workspaceId, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
|
||||||
const indexPath = await this.getIndexPath(workspaceId)
|
const indexPath = await this.getIndexPath(workspaceId)
|
||||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||||
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
this.deps.eventBus.publish({
|
this.deps.eventBus.publish({
|
||||||
type: "instance.event",
|
type: "instance.event",
|
||||||
instanceId: workspaceId,
|
instanceId: workspaceId,
|
||||||
event: { type: "background.process.updated", properties: { process: record } },
|
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
workspaceId: record.workspaceId,
|
||||||
|
title: record.title,
|
||||||
|
command: record.command,
|
||||||
|
cwd: record.cwd,
|
||||||
|
status: record.status,
|
||||||
|
pid: record.pid,
|
||||||
|
startedAt: record.startedAt,
|
||||||
|
stoppedAt: record.stoppedAt,
|
||||||
|
exitCode: record.exitCode,
|
||||||
|
outputSizeBytes: record.outputSizeBytes,
|
||||||
|
terminalReason: record.terminalReason,
|
||||||
|
notifyEnabled: Boolean(record.notify),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||||
|
if (this.shouldSendCompletionPrompt(record, completion)) {
|
||||||
|
try {
|
||||||
|
await this.sendCompletionPrompt(workspaceId, record)
|
||||||
|
if (record.notify) {
|
||||||
|
record.notify.sentAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completion.removeAfterFinalize) {
|
||||||
|
await this.removeFromIndex(workspaceId, record.id)
|
||||||
|
await this.removeProcessDir(workspaceId, record.id)
|
||||||
|
|
||||||
|
this.deps.eventBus.publish({
|
||||||
|
type: "instance.event",
|
||||||
|
instanceId: workspaceId,
|
||||||
|
event: { type: "background.process.removed", properties: { processId: record.id } },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertIndex(workspaceId, record)
|
||||||
|
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||||
|
this.publishUpdate(workspaceId, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||||
|
if (completion.endContext === "workspace_cleanup") return false
|
||||||
|
if (!record.notify) return false
|
||||||
|
return !record.notify.sentAt
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
|
const notify = record.notify
|
||||||
|
if (!notify || !record.terminalReason) return
|
||||||
|
|
||||||
|
if (!this.deps.workspaceManager.get(workspaceId)) {
|
||||||
|
throw new Error("Workspace not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
|
||||||
|
if (!port) {
|
||||||
|
throw new Error("Workspace instance is not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
if (authorization) {
|
||||||
|
headers.authorization = authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: this.buildSyntheticCompletionPrompt(record),
|
||||||
|
synthetic: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Prompt request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||||
|
const ref = `Background process "${record.title}" (${record.id})`
|
||||||
|
|
||||||
|
switch (record.terminalReason) {
|
||||||
|
case "finished":
|
||||||
|
return `${ref} finished successfully.`
|
||||||
|
case "failed":
|
||||||
|
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
|
||||||
|
case "user_stopped":
|
||||||
|
return `${ref} was stopped by user.`
|
||||||
|
case "user_terminated":
|
||||||
|
return `${ref} was terminated by user.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ref} ended.`
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||||
|
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeTaggedText(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
}
|
||||||
|
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||||
const random = randomBytes(3).toString("hex")
|
const random = randomBytes(3).toString("hex")
|
||||||
|
|||||||
128
packages/server/src/clients/connection-manager.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
const STALE_CONNECTION_TIMEOUT_MS = 45000
|
||||||
|
const STALE_SWEEP_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
export interface ClientConnectionRef {
|
||||||
|
clientId: string
|
||||||
|
connectionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientConnectionRecord extends ClientConnectionRef {
|
||||||
|
key: string
|
||||||
|
connectedAt: number
|
||||||
|
lastSeenAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionChangeEvent = {
|
||||||
|
type: "connected" | "disconnected"
|
||||||
|
connection: ClientConnectionRecord
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisteredConnection extends ClientConnectionRecord {
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientConnectionManager {
|
||||||
|
private readonly connections = new Map<string, RegisteredConnection>()
|
||||||
|
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
|
||||||
|
private readonly sweepTimer: NodeJS.Timeout
|
||||||
|
|
||||||
|
constructor(private readonly logger: Logger) {
|
||||||
|
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
|
||||||
|
this.sweepTimer.unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
clearInterval(this.sweepTimer)
|
||||||
|
for (const connection of Array.from(this.connections.values())) {
|
||||||
|
this.disconnect(connection.key, "shutdown", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
|
||||||
|
this.subscribers.add(listener)
|
||||||
|
return () => this.subscribers.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
register(input: ClientConnectionRef & { close: () => void }): () => void {
|
||||||
|
const key = getConnectionKey(input)
|
||||||
|
const now = Date.now()
|
||||||
|
const existing = this.connections.get(key)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
|
||||||
|
this.disconnect(key, "replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: RegisteredConnection = {
|
||||||
|
key,
|
||||||
|
clientId: input.clientId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
connectedAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
close: input.close,
|
||||||
|
}
|
||||||
|
this.connections.set(key, connection)
|
||||||
|
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
|
||||||
|
this.notify({ type: "connected", connection })
|
||||||
|
return () => this.disconnect(key, "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pong(input: ClientConnectionRef): boolean {
|
||||||
|
const key = getConnectionKey(input)
|
||||||
|
const connection = this.connections.get(key)
|
||||||
|
if (!connection) {
|
||||||
|
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.lastSeenAt = Date.now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(input: ClientConnectionRef): boolean {
|
||||||
|
return this.connections.has(getConnectionKey(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
private sweepStaleConnections(): void {
|
||||||
|
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
|
||||||
|
for (const connection of Array.from(this.connections.values())) {
|
||||||
|
if (connection.lastSeenAt > cutoff) continue
|
||||||
|
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
|
||||||
|
this.disconnect(connection.key, "timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private disconnect(key: string, reason: string, invokeClose = true): void {
|
||||||
|
const connection = this.connections.get(key)
|
||||||
|
if (!connection) return
|
||||||
|
this.connections.delete(key)
|
||||||
|
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
|
||||||
|
|
||||||
|
if (invokeClose) {
|
||||||
|
try {
|
||||||
|
connection.close()
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notify({ type: "disconnected", connection, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(event: ConnectionChangeEvent): void {
|
||||||
|
for (const subscriber of this.subscribers) {
|
||||||
|
try {
|
||||||
|
subscriber(event)
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionKey(input: ClientConnectionRef): string {
|
||||||
|
return `${input.clientId}:${input.connectionId}`
|
||||||
|
}
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import {
|
|
||||||
BinaryCreateRequest,
|
|
||||||
BinaryRecord,
|
|
||||||
BinaryUpdateRequest,
|
|
||||||
BinaryValidationResult,
|
|
||||||
} from "../api-types"
|
|
||||||
import { spawnSync } from "child_process"
|
|
||||||
import { ConfigStore } from "./store"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import type { ConfigFile } from "./schema"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
import { buildSpawnSpec } from "../workspaces/runtime"
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const inputPath = record.path
|
|
||||||
if (!inputPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(inputPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = (result.stdout ?? "").trim()
|
|
||||||
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
|
||||||
const normalized = firstLine?.trim()
|
|
||||||
|
|
||||||
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
|
|
||||||
return { valid: true, version }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
|||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||||
|
|
||||||
// OS notifications
|
// OS notifications
|
||||||
osNotificationsEnabled: z.boolean().default(false),
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
import {
|
|
||||||
ConfigFile,
|
|
||||||
ConfigFileSchema,
|
|
||||||
ConfigYamlSchema,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
DEFAULT_CONFIG_YAML,
|
|
||||||
DEFAULT_STATE,
|
|
||||||
StateFile,
|
|
||||||
StateFileSchema,
|
|
||||||
} from "./schema"
|
|
||||||
import type { ConfigLocation } from "./location"
|
|
||||||
|
|
||||||
export class ConfigStore {
|
|
||||||
private cache: ConfigFile = DEFAULT_CONFIG
|
|
||||||
private state: StateFile = DEFAULT_STATE
|
|
||||||
private loaded = false
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly location: ConfigLocation,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
load(): ConfigFile {
|
|
||||||
if (this.loaded) {
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configYamlPath = this.location.configYamlPath
|
|
||||||
const stateYamlPath = this.location.stateYamlPath
|
|
||||||
const legacyJsonPath = this.location.legacyJsonPath
|
|
||||||
|
|
||||||
if (fs.existsSync(configYamlPath)) {
|
|
||||||
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
|
||||||
const stateDoc = fs.existsSync(stateYamlPath)
|
|
||||||
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
|
||||||
: DEFAULT_STATE
|
|
||||||
|
|
||||||
this.state = stateDoc
|
|
||||||
this.cache = this.mergeDocs(configDoc, stateDoc)
|
|
||||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
|
||||||
} else if (fs.existsSync(legacyJsonPath)) {
|
|
||||||
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
|
||||||
this.state = migrated.state
|
|
||||||
this.cache = migrated.config
|
|
||||||
} else {
|
|
||||||
// Fresh install: write defaults.
|
|
||||||
this.state = DEFAULT_STATE
|
|
||||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
|
||||||
this.persist()
|
|
||||||
this.logger.debug(
|
|
||||||
{ configYamlPath, stateYamlPath },
|
|
||||||
"No config files found, created default YAML config/state",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
|
||||||
this.state = DEFAULT_STATE
|
|
||||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loaded = true
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): ConfigFile {
|
|
||||||
return this.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
replace(config: ConfigFile) {
|
|
||||||
const validated = ConfigFileSchema.parse(config)
|
|
||||||
this.commit(validated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a merge-patch update to the current config.
|
|
||||||
* - Missing keys are preserved.
|
|
||||||
* - Object values are merged recursively.
|
|
||||||
* - Explicit `null` deletes keys.
|
|
||||||
* - Arrays are replaced.
|
|
||||||
*/
|
|
||||||
mergePatch(patch: unknown) {
|
|
||||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
||||||
throw new Error("Config patch must be a JSON object")
|
|
||||||
}
|
|
||||||
const current = this.get()
|
|
||||||
const next = applyMergePatch(current as any, patch as any)
|
|
||||||
const validated = ConfigFileSchema.parse(next)
|
|
||||||
this.commit(validated)
|
|
||||||
}
|
|
||||||
|
|
||||||
private commit(next: ConfigFile) {
|
|
||||||
this.cache = next
|
|
||||||
this.loaded = true
|
|
||||||
this.state = {
|
|
||||||
...this.state,
|
|
||||||
recentFolders: next.recentFolders,
|
|
||||||
}
|
|
||||||
this.persist()
|
|
||||||
const published = Boolean(this.eventBus)
|
|
||||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
|
||||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
|
||||||
this.logger.trace({ config: this.cache }, "Config payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
private persist() {
|
|
||||||
try {
|
|
||||||
const configYamlPath = this.location.configYamlPath
|
|
||||||
const stateYamlPath = this.location.stateYamlPath
|
|
||||||
|
|
||||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
|
||||||
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
|
||||||
|
|
||||||
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
|
||||||
const stateYaml = stringifyYaml(this.state as any)
|
|
||||||
|
|
||||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
|
||||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
|
||||||
|
|
||||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to persist config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
|
||||||
const merged = {
|
|
||||||
...(configDoc as any),
|
|
||||||
// State wins for recent folders.
|
|
||||||
recentFolders: stateDoc.recentFolders ?? [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConfigFileSchema.parse(merged)
|
|
||||||
}
|
|
||||||
|
|
||||||
private readYamlFile<T>(
|
|
||||||
filePath: string,
|
|
||||||
fallback: T,
|
|
||||||
schema: { parse: (value: unknown) => T },
|
|
||||||
label: string,
|
|
||||||
): T {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8")
|
|
||||||
const parsed = parseYaml(content)
|
|
||||||
return schema.parse(parsed ?? {})
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
|
||||||
const configYamlPath = this.location.configYamlPath
|
|
||||||
const stateYamlPath = this.location.stateYamlPath
|
|
||||||
|
|
||||||
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
|
||||||
const parsed = JSON.parse(content)
|
|
||||||
const legacy = ConfigFileSchema.parse(parsed)
|
|
||||||
|
|
||||||
const state: StateFile = StateFileSchema.parse({
|
|
||||||
...DEFAULT_STATE,
|
|
||||||
recentFolders: legacy.recentFolders ?? [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
|
||||||
|
|
||||||
// Persist YAML docs first, then move legacy aside.
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
|
||||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
|
||||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
|
||||||
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const bakPath = pickBackupPath(legacyJsonPath)
|
|
||||||
fs.renameSync(legacyJsonPath, bakPath)
|
|
||||||
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
|
||||||
}
|
|
||||||
|
|
||||||
return { config: merged, state }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTrailingNewline(content: string): string {
|
|
||||||
if (!content) return "\n"
|
|
||||||
return content.endsWith("\n") ? content : `${content}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
|
||||||
const clone: Record<string, unknown> = { ...(config as any) }
|
|
||||||
delete clone.recentFolders
|
|
||||||
return clone as any
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
if (!value || typeof value !== "object") return false
|
|
||||||
if (Array.isArray(value)) return false
|
|
||||||
const proto = Object.getPrototypeOf(value)
|
|
||||||
return proto === Object.prototype || proto === null
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMergePatch(current: any, patch: any): any {
|
|
||||||
// RFC 7396-ish merge patch with explicit null deletes.
|
|
||||||
if (!isPlainObject(patch)) {
|
|
||||||
return patch
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = isPlainObject(current) ? { ...current } : {}
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
if (value === null) {
|
|
||||||
delete base[key]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainObject(value) && isPlainObject(base[key])) {
|
|
||||||
base[key] = applyMergePatch(base[key], value)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrays and scalars replace.
|
|
||||||
base[key] = value
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBackupPath(legacyJsonPath: string): string {
|
|
||||||
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
|
||||||
const preferred = `${base}.json.bak`
|
|
||||||
if (!fs.existsSync(preferred)) {
|
|
||||||
return preferred
|
|
||||||
}
|
|
||||||
return `${base}.json.bak.${Date.now()}`
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,10 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("config.appChanged", handler)
|
this.on("sidecar.updated", handler)
|
||||||
this.on("config.binariesChanged", handler)
|
this.on("sidecar.removed", handler)
|
||||||
|
this.on("storage.configChanged", handler)
|
||||||
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
@@ -35,8 +37,10 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("config.appChanged", handler)
|
this.off("sidecar.updated", handler)
|
||||||
this.off("config.binariesChanged", handler)
|
this.off("sidecar.removed", handler)
|
||||||
|
this.off("storage.configChanged", handler)
|
||||||
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
|||||||
return { path: relativePath, absolutePath }
|
return { path: relativePath, absolutePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeFile(relativePath: string, contents: string): void {
|
||||||
|
if (this.unrestricted) {
|
||||||
|
throw new Error("writeFile is not available in unrestricted mode")
|
||||||
|
}
|
||||||
|
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||||
|
fs.writeFileSync(resolved, contents, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
readFile(relativePath: string): string {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
|
|||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
import { ConfigStore } from "./config/store"
|
|
||||||
import { resolveConfigLocation } from "./config/location"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { BinaryRegistry } from "./config/binaries"
|
import { SettingsService } from "./settings/service"
|
||||||
|
import { BinaryResolver } from "./settings/binaries"
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
@@ -19,10 +19,16 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
|||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { RemoteProxySessionManager } from "./server/remote-proxy"
|
||||||
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
import { SpeechService } from "./speech/service"
|
||||||
|
import { SideCarManager } from "./sidecars/manager"
|
||||||
|
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||||
|
import { PluginChannelManager } from "./plugins/channel"
|
||||||
|
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -54,6 +60,7 @@ interface CliOptions {
|
|||||||
launch: boolean
|
launch: boolean
|
||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth: boolean
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
@@ -78,7 +85,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
.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("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||||
@@ -99,6 +106,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.default(DEFAULT_AUTH_USERNAME),
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
|
.addOption(
|
||||||
|
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||||
|
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||||
|
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||||
|
)
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
.env("CODENOMAD_GENERATE_TOKEN")
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
@@ -138,6 +150,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch?: boolean
|
launch?: boolean
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken?: boolean
|
generateToken?: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -184,6 +197,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
authUsername: parsed.username,
|
authUsername: parsed.username,
|
||||||
authPassword: parsed.password,
|
authPassword: parsed.password,
|
||||||
|
authCookieName: parsed.authCookieName,
|
||||||
generateToken: Boolean(parsed.generateToken),
|
generateToken: Boolean(parsed.generateToken),
|
||||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
@@ -265,6 +279,7 @@ async function main() {
|
|||||||
configPath: configLocation.configYamlPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
|
cookieName: options.authCookieName,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
},
|
},
|
||||||
@@ -291,21 +306,12 @@ async function main() {
|
|||||||
|
|
||||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||||
|
const binaryResolver = new BinaryResolver(settings)
|
||||||
// Eagerly load config at boot so migrations run immediately
|
|
||||||
// (instead of waiting for the first /api/config request).
|
|
||||||
try {
|
|
||||||
configStore.get()
|
|
||||||
} catch (error) {
|
|
||||||
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
|
||||||
}
|
|
||||||
|
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
binaryResolver,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.localUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
@@ -313,6 +319,12 @@ async function main() {
|
|||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
|
const sidecarManager = new SideCarManager({
|
||||||
|
settings,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "sidecars" }),
|
||||||
|
})
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -364,12 +376,21 @@ async function main() {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (uiResolution.uiDevServerUrl && options.https) {
|
|
||||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
|
||||||
|
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||||
|
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
||||||
|
const remoteProxySessionManager = new RemoteProxySessionManager({
|
||||||
|
authManager,
|
||||||
|
logger: logger.child({ component: "remote-proxy" }),
|
||||||
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
|
})
|
||||||
|
const voiceModeManager = new VoiceModeManager({
|
||||||
|
connections: clientConnectionManager,
|
||||||
|
channel: pluginChannel,
|
||||||
|
logger: logger.child({ component: "voice-mode" }),
|
||||||
|
})
|
||||||
|
|
||||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||||
|
|
||||||
@@ -392,13 +413,18 @@ async function main() {
|
|||||||
defaultPort: options.httpPort,
|
defaultPort: options.httpPort,
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
@@ -413,13 +439,18 @@ async function main() {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
httpsOptions: tlsResolution?.httpsOptions,
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
@@ -449,18 +480,22 @@ async function main() {
|
|||||||
// which can lead clients to talk to the wrong process.
|
// which can lead clients to talk to the wrong process.
|
||||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
let remoteUrl: string | undefined
|
let remoteUrl: string | undefined
|
||||||
|
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||||
if (remoteStart) {
|
if (remoteStart) {
|
||||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
let remoteHost = options.host
|
let remoteHost = options.host
|
||||||
if (wantsAll) {
|
if (wantsAll) {
|
||||||
if (options.host === "0.0.0.0") {
|
if (options.host === "0.0.0.0") {
|
||||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
remoteAddresses = resolved.userVisible
|
||||||
|
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remoteHost = "localhost"
|
remoteHost = "localhost"
|
||||||
}
|
}
|
||||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
if (!remoteUrl) {
|
||||||
|
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serverMeta.localUrl = localUrl
|
serverMeta.localUrl = localUrl
|
||||||
@@ -471,7 +506,9 @@ async function main() {
|
|||||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||||
|
|
||||||
if (serverMeta.remotePort && remoteUrl) {
|
if (serverMeta.remotePort && remoteUrl) {
|
||||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
serverMeta.addresses = remoteAddresses.length
|
||||||
|
? remoteAddresses
|
||||||
|
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||||
} else {
|
} else {
|
||||||
serverMeta.addresses = []
|
serverMeta.addresses = []
|
||||||
}
|
}
|
||||||
@@ -479,6 +516,16 @@ async function main() {
|
|||||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||||
if (serverMeta.remoteUrl) {
|
if (serverMeta.remoteUrl) {
|
||||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||||
|
const additionalRemoteUrls = serverMeta.addresses
|
||||||
|
.map((addr) => addr.remoteUrl)
|
||||||
|
.filter((url) => url !== serverMeta.remoteUrl)
|
||||||
|
|
||||||
|
if (additionalRemoteUrls.length > 0) {
|
||||||
|
console.log("Other Accessible URLs:")
|
||||||
|
for (const url of additionalRemoteUrls) {
|
||||||
|
console.log(` - ${url}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.launch) {
|
if (options.launch) {
|
||||||
@@ -502,6 +549,18 @@ async function main() {
|
|||||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sidecarManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientConnectionManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, "Client connection manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
|||||||
100
packages/server/src/plugins/voice-mode.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
|
||||||
|
import type { PluginChannelManager } from "./channel"
|
||||||
|
|
||||||
|
interface VoiceModeManagerOptions {
|
||||||
|
connections: ClientConnectionManager
|
||||||
|
channel: PluginChannelManager
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoiceModeManager {
|
||||||
|
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
|
||||||
|
private readonly aggregateByInstance = new Map<string, boolean>()
|
||||||
|
|
||||||
|
constructor(private readonly options: VoiceModeManagerOptions) {
|
||||||
|
this.options.connections.subscribe((event) => {
|
||||||
|
if (event.type !== "disconnected") return
|
||||||
|
this.clearConnection(event.connection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
|
||||||
|
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||||
|
this.options.logger.debug(
|
||||||
|
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||||
|
"Ignoring voice mode enable for disconnected client connection",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getConnectionKey(connection)
|
||||||
|
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
current.add(key)
|
||||||
|
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||||
|
} else if (current.delete(key)) {
|
||||||
|
if (current.size === 0) {
|
||||||
|
this.enabledConnectionsByInstance.delete(instanceId)
|
||||||
|
} else {
|
||||||
|
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||||
|
this.publishIfChanged(instanceId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
syncInstance(instanceId: string): void {
|
||||||
|
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(instanceId: string): boolean {
|
||||||
|
return this.aggregateByInstance.get(instanceId) === true
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearConnection(connection: ClientConnectionRef): void {
|
||||||
|
const key = getConnectionKey(connection)
|
||||||
|
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
|
||||||
|
if (!enabledConnections.delete(key)) continue
|
||||||
|
if (enabledConnections.size === 0) {
|
||||||
|
this.enabledConnectionsByInstance.delete(instanceId)
|
||||||
|
}
|
||||||
|
this.publishIfChanged(instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishIfChanged(instanceId: string): void {
|
||||||
|
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
|
||||||
|
const previous = this.aggregateByInstance.get(instanceId) === true
|
||||||
|
if (enabled === previous) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
this.aggregateByInstance.set(instanceId, true)
|
||||||
|
} else {
|
||||||
|
this.aggregateByInstance.delete(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.logger.debug(
|
||||||
|
{ instanceId, enabled },
|
||||||
|
"Broadcasting aggregate voice mode",
|
||||||
|
)
|
||||||
|
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVoiceModeEvent(enabled: boolean) {
|
||||||
|
return {
|
||||||
|
type: "codenomad.voiceMode",
|
||||||
|
properties: {
|
||||||
|
enabled,
|
||||||
|
formatVersion: "v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionKey(connection: ClientConnectionRef): string {
|
||||||
|
return `${connection.clientId}:${connection.connectionId}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import os from "node:os"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||||
|
|
||||||
|
describe("resolveNetworkAddresses", () => {
|
||||||
|
it("preserves interface order among external addresses", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "10.0.0.8", family: 4, internal: false },
|
||||||
|
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.map((entry) => entry.ip),
|
||||||
|
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveRemoteAddresses", () => {
|
||||||
|
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.userVisible.map((entry) => entry.ip),
|
||||||
|
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||||
|
)
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers private LAN addresses over public addresses", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||||
|
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||||
|
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.userVisible.map((entry) => entry.ip),
|
||||||
|
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||||
|
)
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses a public address when no private LAN address is available", () => {
|
||||||
|
const addresses = [
|
||||||
|
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||||
|
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
usingMockedNetworkInterfaces(addresses, () => {
|
||||||
|
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||||
|
|
||||||
|
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||||
|
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function usingMockedNetworkInterfaces(
|
||||||
|
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||||
|
callback: () => void,
|
||||||
|
) {
|
||||||
|
const original = os.networkInterfaces
|
||||||
|
os.networkInterfaces = (() => ({
|
||||||
|
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||||
|
})) as typeof os.networkInterfaces
|
||||||
|
|
||||||
|
try {
|
||||||
|
callback()
|
||||||
|
} finally {
|
||||||
|
os.networkInterfaces = original
|
||||||
|
}
|
||||||
|
}
|
||||||
248
packages/server/src/server/__tests__/remote-proxy.test.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { after, afterEach, describe, it } from "node:test"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import http, { type IncomingMessage, type ServerResponse } from "node:http"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
|
||||||
|
import type { AuthManager } from "../../auth/manager"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import { RemoteProxySessionManager } from "../remote-proxy"
|
||||||
|
import { resolveHttpsOptions } from "../tls"
|
||||||
|
|
||||||
|
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
|
||||||
|
const sharedTls = resolveHttpsOptions({
|
||||||
|
enabled: true,
|
||||||
|
configDir: sharedTempDir,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
logger: createStubLogger(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sharedTls) {
|
||||||
|
throw new Error("Failed to generate HTTPS options for remote proxy tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedHttpsOptions = sharedTls.httpsOptions
|
||||||
|
|
||||||
|
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
|
||||||
|
const managers = new Set<RemoteProxySessionManager>()
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const manager of managers) {
|
||||||
|
await disposeManager(manager)
|
||||||
|
}
|
||||||
|
managers.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(sharedTempDir, { recursive: true, force: true })
|
||||||
|
httpsDispatcher.close().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("RemoteProxySessionManager", () => {
|
||||||
|
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
|
||||||
|
assert.equal(blocked.status, 403)
|
||||||
|
|
||||||
|
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: session2.token }),
|
||||||
|
})
|
||||||
|
assert.equal(wrongTokenResponse.status, 401)
|
||||||
|
|
||||||
|
assert.equal(await activateSession(session1), true)
|
||||||
|
assert.equal(await activateSession(session2), true)
|
||||||
|
}, (req, res) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end(req.url ?? "")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
await activateSession(session)
|
||||||
|
|
||||||
|
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
|
||||||
|
assert.equal(apiResponse.status, 200)
|
||||||
|
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
|
||||||
|
|
||||||
|
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
|
||||||
|
assert.equal(redirectResponse.status, 302)
|
||||||
|
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
|
||||||
|
}, (req, res) => {
|
||||||
|
const requestUrl = req.url ?? ""
|
||||||
|
if (requestUrl === "/base/redirect") {
|
||||||
|
res.writeHead(302, { location: "/base/after?ok=1" })
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end(requestUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
await activateSession(session)
|
||||||
|
|
||||||
|
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
|
||||||
|
assert.equal(loginResponse.status, 200)
|
||||||
|
const setCookie = getSetCookie(loginResponse)[0]
|
||||||
|
|
||||||
|
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
|
||||||
|
assert.doesNotMatch(setCookie, /domain=/i)
|
||||||
|
|
||||||
|
const cookieHeader = setCookie.split(";", 1)[0]
|
||||||
|
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(await whoamiResponse.text(), "session=abc123")
|
||||||
|
}, (req, res) => {
|
||||||
|
const requestUrl = req.url ?? ""
|
||||||
|
if (requestUrl === "/base/login") {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
|
||||||
|
})
|
||||||
|
res.end("ok")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestUrl === "/base/whoami") {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end(req.headers.cookie ?? "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { "content-type": "text/plain" })
|
||||||
|
res.end(requestUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports explicit deletion and idle cleanup of sessions", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
assert.equal(await manager.deleteSession(session.sessionId), true)
|
||||||
|
assert.equal(await manager.deleteSession(session.sessionId), false)
|
||||||
|
|
||||||
|
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
|
||||||
|
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
|
||||||
|
|
||||||
|
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
|
||||||
|
await internalCleanup.call(manager)
|
||||||
|
|
||||||
|
assert.equal(internalSessions.has(session3.sessionId), false)
|
||||||
|
assert.equal(await manager.deleteSession(session3.sessionId), false)
|
||||||
|
}, (_req, res) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end("ok")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function createSessionManager() {
|
||||||
|
const manager = new RemoteProxySessionManager({
|
||||||
|
authManager: {
|
||||||
|
isLoopbackRequest: () => true,
|
||||||
|
} as unknown as AuthManager,
|
||||||
|
logger: createStubLogger(),
|
||||||
|
httpsOptions: sharedHttpsOptions,
|
||||||
|
})
|
||||||
|
managers.add(manager)
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
|
||||||
|
const created = await manager.createSession(baseUrl, false)
|
||||||
|
const windowUrl = new URL(created.windowUrl)
|
||||||
|
return {
|
||||||
|
sessionId: created.sessionId,
|
||||||
|
windowUrl,
|
||||||
|
proxyOrigin: windowUrl.origin,
|
||||||
|
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateSession(session: { proxyOrigin: string; token: string }) {
|
||||||
|
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: session.token }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { ok?: boolean }
|
||||||
|
return body.ok === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
|
||||||
|
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
|
||||||
|
if (Array.isArray(values) && values.length > 0) {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
const fallback = response.headers.get("set-cookie")
|
||||||
|
return fallback ? [fallback] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
|
||||||
|
return fetch(url, { dispatcher: httpsDispatcher, ...init })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disposeManager(manager: RemoteProxySessionManager) {
|
||||||
|
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
|
||||||
|
for (const sessionId of sessions) {
|
||||||
|
await manager.deleteSession(sessionId)
|
||||||
|
}
|
||||||
|
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withUpstreamServer(
|
||||||
|
callback: (baseUrl: string) => Promise<void>,
|
||||||
|
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
|
||||||
|
) {
|
||||||
|
const server = http.createServer(handler)
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Failed to resolve upstream server address")
|
||||||
|
}
|
||||||
|
await callback(`http://127.0.0.1:${address.port}`)
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubLogger(): Logger {
|
||||||
|
const logger = {
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
child() {
|
||||||
|
return logger
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger as unknown as Logger
|
||||||
|
}
|
||||||
@@ -3,18 +3,20 @@ import cors from "@fastify/cors"
|
|||||||
import fastifyStatic from "@fastify/static"
|
import fastifyStatic from "@fastify/static"
|
||||||
import replyFrom from "@fastify/reply-from"
|
import replyFrom from "@fastify/reply-from"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { connect as connectTls, type TLSSocket } from "tls"
|
||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
|
||||||
|
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
import { registerConfigRoutes } from "./routes/config"
|
import { registerSettingsRoutes } from "./routes/settings"
|
||||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
@@ -22,12 +24,22 @@ import { registerStorageRoutes } from "./routes/storage"
|
|||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
|
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||||
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
||||||
|
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
import type { AuthManager } from "../auth/manager"
|
import type { AuthManager } from "../auth/manager"
|
||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
import type { SpeechService } from "../speech/service"
|
||||||
|
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||||
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -37,13 +49,18 @@ interface HttpServerDeps {
|
|||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
|
||||||
fileSystemBrowser: FileSystemBrowser
|
fileSystemBrowser: FileSystemBrowser
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
speechService: SpeechService
|
||||||
|
sidecarManager: SideCarManager
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
|
clientConnectionManager: ClientConnectionManager
|
||||||
|
pluginChannel: PluginChannelManager
|
||||||
|
voiceModeManager: VoiceModeManager
|
||||||
|
remoteProxySessionManager: RemoteProxySessionManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -185,14 +202,19 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
publicPagePaths.add("/auth/token")
|
publicPagePaths.add("/auth/token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
const isLoopbackRemoteProxyDelete =
|
||||||
|
request.method === "DELETE" &&
|
||||||
|
pathname.startsWith("/api/remote-proxy/sessions/") &&
|
||||||
|
deps.authManager.isLoopbackRequest(request)
|
||||||
|
|
||||||
|
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
|
||||||
done()
|
done()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = deps.authManager.getSessionFromRequest(request)
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||||
if (requiresAuthForApi && !session) {
|
if (requiresAuthForApi && !session) {
|
||||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
@@ -244,17 +266,38 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, {
|
||||||
|
eventBus: deps.eventBus,
|
||||||
|
registerClient: registerSseClient,
|
||||||
|
logger: sseLogger,
|
||||||
|
connectionManager: deps.clientConnectionManager,
|
||||||
|
})
|
||||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
instanceStore: deps.instanceStore,
|
instanceStore: deps.instanceStore,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
|
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
|
||||||
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||||
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||||
|
setupSideCarWebSocketProxy(app, {
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: proxyLogger,
|
||||||
|
})
|
||||||
|
registerPluginRoutes(app, {
|
||||||
|
workspaceManager: deps.workspaceManager,
|
||||||
|
eventBus: deps.eventBus,
|
||||||
|
logger: proxyLogger,
|
||||||
|
channel: deps.pluginChannel,
|
||||||
|
voiceModeManager: deps.voiceModeManager,
|
||||||
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
@@ -329,6 +372,68 @@ interface InstanceProxyDeps {
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SideCarProxyDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||||
|
const proxyBaseHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyWildcardHandler = async (
|
||||||
|
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) => {
|
||||||
|
await proxySideCarRequest({
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
pathSuffix: request.params["*"] ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/sidecars/:id", proxyBaseHandler)
|
||||||
|
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||||
|
app.server.on("upgrade", (request, socket, head) => {
|
||||||
|
const rawUrl = request.url ?? "/"
|
||||||
|
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||||
|
if (!parsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void proxySideCarWebSocketUpgrade({
|
||||||
|
request,
|
||||||
|
socket: socket as Socket,
|
||||||
|
head,
|
||||||
|
sidecarId: parsed.sidecarId,
|
||||||
|
incomingPath: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
sidecarManager: deps.sidecarManager,
|
||||||
|
authManager: deps.authManager,
|
||||||
|
logger: deps.logger,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||||
app.register(async (instance) => {
|
app.register(async (instance) => {
|
||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
@@ -369,6 +474,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||||||
|
|
||||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||||
|
|
||||||
|
// Special-case OpenCode directory override.
|
||||||
|
//
|
||||||
|
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||||
|
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||||
|
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||||
|
// before proxying to the instance.
|
||||||
|
//
|
||||||
|
// Example proxied request path:
|
||||||
|
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||||
|
//
|
||||||
|
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||||
|
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||||
|
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||||
|
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||||
|
|
||||||
async function proxyWorkspaceRequest(args: {
|
async function proxyWorkspaceRequest(args: {
|
||||||
request: FastifyRequest
|
request: FastifyRequest
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
@@ -459,19 +579,43 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = await resolveWorktreeDirectory({
|
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||||
workspaceId,
|
try {
|
||||||
workspacePath: workspace.path,
|
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||||
worktreeSlug,
|
} catch (error) {
|
||||||
logger,
|
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||||
})
|
reply.code(400).send({ error: message })
|
||||||
|
|
||||||
if (!directory) {
|
|
||||||
reply.code(404).send({ error: "Worktree not found" })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let directory: string | null = null
|
||||||
|
let forwardedSuffix = extracted.forwardedSuffix
|
||||||
|
|
||||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
if (extracted.overrideDirectory) {
|
||||||
|
try {
|
||||||
|
directory = validateAndNormalizeOverrideDirectory({
|
||||||
|
overrideDirectory: extracted.overrideDirectory,
|
||||||
|
workspaceRoot: workspace.path,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||||
|
reply.code(400).send({ error: message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
directory = await resolveWorktreeDirectory({
|
||||||
|
workspaceId,
|
||||||
|
workspacePath: workspace.path,
|
||||||
|
worktreeSlug,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404).send({ error: "Worktree not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||||
@@ -535,6 +679,89 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||||
|
overrideDirectory: string | null
|
||||||
|
forwardedSuffix: string | undefined
|
||||||
|
} {
|
||||||
|
if (!pathSuffix) {
|
||||||
|
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify wildcard param does not include a leading slash.
|
||||||
|
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||||
|
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||||
|
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||||
|
const slashIndex = rest.indexOf("/")
|
||||||
|
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||||
|
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||||
|
|
||||||
|
if (!encoded) {
|
||||||
|
throw new Error("Missing directory override")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||||
|
throw new Error("Directory override too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
let overrideDirectory = ""
|
||||||
|
try {
|
||||||
|
overrideDirectory = decodeBase64Url(encoded)
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid directory override")
|
||||||
|
}
|
||||||
|
const forwardedSuffix = remaining
|
||||||
|
return { overrideDirectory, forwardedSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Url(input: string): string {
|
||||||
|
// base64url -> base64
|
||||||
|
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||||
|
const base64 = `${normalized}${padding}`
|
||||||
|
return Buffer.from(base64, "base64").toString("utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||||
|
const raw = params.overrideDirectory.trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Override directory is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.isAbsolute(raw)) {
|
||||||
|
throw new Error("Override directory must be an absolute path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(raw)) {
|
||||||
|
throw new Error(`Override directory does not exist: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(raw)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error(`Override path is not a directory: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedOverride = fs.realpathSync(raw)
|
||||||
|
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||||
|
|
||||||
|
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||||
|
throw new Error("Override directory must be within the workspace root")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubpath(candidate: string, root: string): boolean {
|
||||||
|
const rel = path.relative(root, candidate)
|
||||||
|
if (rel === "") return true
|
||||||
|
if (rel === "..") return false
|
||||||
|
if (rel.startsWith(`..${path.sep}`)) return false
|
||||||
|
if (path.isAbsolute(rel)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||||
if (!pathSuffix || pathSuffix === "/") {
|
if (!pathSuffix || pathSuffix === "/") {
|
||||||
return "/"
|
return "/"
|
||||||
@@ -543,52 +770,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorktreeCacheEntry = {
|
|
||||||
expiresAt: number
|
|
||||||
repoRoot: string
|
|
||||||
worktrees: Array<{ slug: string; directory: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKTREE_CACHE_TTL_MS = 2000
|
|
||||||
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
|
||||||
|
|
||||||
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
|
||||||
const cached = worktreeCache.get(params.workspaceId)
|
|
||||||
const now = Date.now()
|
|
||||||
if (cached && cached.expiresAt > now) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
|
||||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
|
||||||
const entry: WorktreeCacheEntry = {
|
|
||||||
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
|
||||||
repoRoot,
|
|
||||||
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
|
||||||
}
|
|
||||||
worktreeCache.set(params.workspaceId, entry)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveWorktreeDirectory(params: {
|
|
||||||
workspaceId: string
|
|
||||||
workspacePath: string
|
|
||||||
worktreeSlug: string
|
|
||||||
logger: Logger
|
|
||||||
}): Promise<string | null> {
|
|
||||||
const { worktreeSlug } = params
|
|
||||||
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
|
||||||
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
|
||||||
if (match) {
|
|
||||||
return match.directory
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the slug is new (e.g., created moments ago), refresh once.
|
|
||||||
worktreeCache.delete(params.workspaceId)
|
|
||||||
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
|
||||||
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
app.log.warn("UI static directory not provided; API endpoints only")
|
||||||
@@ -691,3 +872,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proxySideCarRequest(args: {
|
||||||
|
request: FastifyRequest
|
||||||
|
reply: FastifyReply
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
logger: Logger
|
||||||
|
pathSuffix?: string
|
||||||
|
}) {
|
||||||
|
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||||
|
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
args.reply.code(404).send({ error: "SideCar not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||||
|
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||||
|
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||||
|
const pathSuffix = args.pathSuffix ?? ""
|
||||||
|
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||||
|
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||||
|
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetUrl = `${targetOrigin}${targetPath}`
|
||||||
|
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||||
|
|
||||||
|
await args.reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||||
|
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||||
|
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||||
|
onError: (reply, { error }) => {
|
||||||
|
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl, "http://localhost")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||||
|
pathname: parsed.pathname,
|
||||||
|
search: parsed.search,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxySideCarWebSocketUpgrade(args: {
|
||||||
|
request: import("http").IncomingMessage
|
||||||
|
socket: Socket
|
||||||
|
head: Buffer
|
||||||
|
sidecarId: string
|
||||||
|
incomingPath: string
|
||||||
|
search: string
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
authManager: AuthManager
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||||
|
|
||||||
|
if (!isWebSocketUpgradeRequest(request)) {
|
||||||
|
rejectUpgrade(socket, 400, "Bad Request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromHeaders(request.headers)
|
||||||
|
if (!session) {
|
||||||
|
rejectUpgrade(socket, 401, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidecar = await sidecarManager.get(sidecarId)
|
||||||
|
if (!sidecar) {
|
||||||
|
rejectUpgrade(socket, 404, "Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||||
|
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||||
|
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||||
|
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||||
|
|
||||||
|
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||||
|
|
||||||
|
const closeBoth = () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream.once("error", (error) => {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||||
|
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("error", (error) => {
|
||||||
|
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once(readyEvent, () => {
|
||||||
|
try {
|
||||||
|
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||||
|
if (head.length > 0) {
|
||||||
|
upstream.write(head)
|
||||||
|
}
|
||||||
|
upstream.pipe(socket)
|
||||||
|
socket.pipe(upstream)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||||
|
closeBoth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream.once("close", () => {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.once("close", () => {
|
||||||
|
if (!upstream.destroyed) {
|
||||||
|
upstream.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||||
|
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||||
|
if (targetUrl.protocol === "https:") {
|
||||||
|
return {
|
||||||
|
socket: connectTls({
|
||||||
|
host: targetUrl.hostname,
|
||||||
|
port,
|
||||||
|
servername: targetUrl.hostname,
|
||||||
|
}),
|
||||||
|
readyEvent: "secureConnect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
socket: connectTcp(port, targetUrl.hostname),
|
||||||
|
readyEvent: "connect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||||
|
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||||
|
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||||
|
const headerLines: string[] = []
|
||||||
|
const rawHeaders = request.rawHeaders ?? []
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
|
||||||
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||||
|
const key = rawHeaders[index]
|
||||||
|
const value = rawHeaders[index + 1]
|
||||||
|
if (!key || value === undefined) continue
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (blockedHeaders.has(lower)) continue
|
||||||
|
if (lower === "origin") {
|
||||||
|
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headerLines.push(`${key}: ${value}\r\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||||
|
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||||
|
headerLines.push("\r\n")
|
||||||
|
|
||||||
|
return requestLine + headerLines.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||||
|
const upgrade = request.headers.upgrade
|
||||||
|
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const connection = request.headers.connection
|
||||||
|
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||||
|
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSideCarResponseHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
sidecarId: string,
|
||||||
|
targetOrigin: string,
|
||||||
|
prefixMode: "strip" | "preserve",
|
||||||
|
) {
|
||||||
|
if (prefixMode === "preserve") {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...headers }
|
||||||
|
const locationHeader = next.location
|
||||||
|
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||||
|
if (!location) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||||
|
|
||||||
|
if (location.startsWith("/")) {
|
||||||
|
next.location = `${publicBase}${location}`
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(location)
|
||||||
|
if (parsed.origin === targetOrigin) {
|
||||||
|
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relative redirects should continue to resolve against the public sidecar path.
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSideCarProxyRequestHeaders(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
targetOrigin: string,
|
||||||
|
): Record<string, string | string[] | undefined> {
|
||||||
|
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||||
|
const next: Record<string, string | string[] | undefined> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!value) continue
|
||||||
|
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
next.origin = targetOrigin
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||||
|
return new Set([
|
||||||
|
"host",
|
||||||
|
"authorization",
|
||||||
|
"proxy-authorization",
|
||||||
|
"forwarded",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-port",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import type { NetworkAddress } from "../api-types"
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
|
export interface ResolvedRemoteAddresses {
|
||||||
|
all: NetworkAddress[]
|
||||||
|
userVisible: NetworkAddress[]
|
||||||
|
primaryRemoteUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNetworkAddresses(args: {
|
export function resolveNetworkAddresses(args: {
|
||||||
host: string
|
host: string
|
||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
|||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
if (scopeDelta !== 0) return scopeDelta
|
if (scopeDelta !== 0) return scopeDelta
|
||||||
return a.ip.localeCompare(b.ip)
|
|
||||||
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRemoteAddresses(args: {
|
||||||
|
host: string
|
||||||
|
protocol: "http" | "https"
|
||||||
|
port: number
|
||||||
|
}): ResolvedRemoteAddresses {
|
||||||
|
const all = resolveNetworkAddresses(args)
|
||||||
|
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
userVisible,
|
||||||
|
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||||
|
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserVisiblePriority(ip: string): number {
|
||||||
|
if (isPrivateIPv4(ip)) return 0
|
||||||
|
if (isLinkLocalIPv4(ip)) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinkLocalIPv4(ip: string): boolean {
|
||||||
|
const octets = parseIPv4(ip)
|
||||||
|
if (!octets) return false
|
||||||
|
const [first, second] = octets
|
||||||
|
return first === 169 && second === 254
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv4(ip: string): boolean {
|
||||||
|
const octets = parseIPv4(ip)
|
||||||
|
if (!octets) return false
|
||||||
|
const [first, second] = octets
|
||||||
|
|
||||||
|
if (first === 10) return true
|
||||||
|
if (first === 192 && second === 168) return true
|
||||||
|
return first === 172 && second >= 16 && second <= 31
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIPv4(value: string): number[] | null {
|
||||||
|
if (!isIPv4Address(value)) return null
|
||||||
|
return value.split(".").map((part) => Number(part))
|
||||||
|
}
|
||||||
|
|
||||||
function isIPv4Address(value: string | undefined): value is string {
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
const parts = value.split(".")
|
const parts = value.split(".")
|
||||||
|
|||||||
566
packages/server/src/server/remote-proxy.ts
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||||
|
import { randomBytes, randomUUID } from "crypto"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { pipeline } from "stream/promises"
|
||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
import type { AuthManager } from "../auth/manager"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
const LOOPBACK_HOST = "127.0.0.1"
|
||||||
|
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
|
||||||
|
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
|
||||||
|
const SESSION_IDLE_TTL_MS = 30 * 60_000
|
||||||
|
|
||||||
|
interface RemoteProxySession {
|
||||||
|
id: string
|
||||||
|
bootstrapToken: string
|
||||||
|
targetBaseUrl: URL
|
||||||
|
skipTlsVerify: boolean
|
||||||
|
localBaseUrl: URL
|
||||||
|
entryUrl: URL
|
||||||
|
bootstrapUrl: string
|
||||||
|
activated: boolean
|
||||||
|
cookiePrefix: string
|
||||||
|
app: FastifyInstance
|
||||||
|
dispatcher?: Agent
|
||||||
|
createdAt: number
|
||||||
|
lastAccessAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionManagerOptions {
|
||||||
|
authManager: AuthManager
|
||||||
|
logger: Logger
|
||||||
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateResult {
|
||||||
|
sessionId: string
|
||||||
|
windowUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteProxySessionManager {
|
||||||
|
private readonly sessions = new Map<string, RemoteProxySession>()
|
||||||
|
private readonly cleanupTimer: NodeJS.Timeout
|
||||||
|
|
||||||
|
constructor(private readonly options: RemoteProxySessionManagerOptions) {
|
||||||
|
this.cleanupTimer = setInterval(() => {
|
||||||
|
void this.cleanupExpiredSessions()
|
||||||
|
}, 60_000)
|
||||||
|
this.cleanupTimer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
|
||||||
|
if (!this.options.httpsOptions) {
|
||||||
|
throw new Error("Local HTTPS is required for remote proxy sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
||||||
|
const sessionId = randomUUID()
|
||||||
|
const bootstrapToken = randomBytes(32).toString("base64url")
|
||||||
|
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||||
|
const app = Fastify({ logger: false, https: this.options.httpsOptions })
|
||||||
|
let session: RemoteProxySession | null = null
|
||||||
|
|
||||||
|
app.removeAllContentTypeParsers()
|
||||||
|
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
|
||||||
|
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
|
||||||
|
|
||||||
|
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
|
||||||
|
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
reply.header("Pragma", "no-cache")
|
||||||
|
reply.header("Expires", "0")
|
||||||
|
reply.type("text/html").send(buildBootstrapPageHtml())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
|
||||||
|
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parseTokenBody(request.body)
|
||||||
|
if (body.token !== session.bootstrapToken) {
|
||||||
|
reply.code(401).send({ error: "Invalid token" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.activated = true
|
||||||
|
session.lastAccessAt = Date.now()
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.all("/*", async (request, reply) => {
|
||||||
|
if (!session) {
|
||||||
|
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.activated) {
|
||||||
|
reply.code(403).send({ error: "Remote proxy session is not activated" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastAccessAt = Date.now()
|
||||||
|
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.setNotFoundHandler(async (request, reply) => {
|
||||||
|
if (!session) {
|
||||||
|
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.activated) {
|
||||||
|
reply.code(403).send({ error: "Remote proxy session is not activated" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastAccessAt = Date.now()
|
||||||
|
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||||
|
})
|
||||||
|
|
||||||
|
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
|
||||||
|
const address = new URL(addressInfo)
|
||||||
|
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
|
||||||
|
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
|
||||||
|
const returnTo = buildReturnToTarget(entryUrl)
|
||||||
|
|
||||||
|
session = {
|
||||||
|
id: sessionId,
|
||||||
|
bootstrapToken,
|
||||||
|
targetBaseUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
localBaseUrl,
|
||||||
|
entryUrl,
|
||||||
|
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
|
||||||
|
activated: false,
|
||||||
|
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
||||||
|
app,
|
||||||
|
dispatcher,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastAccessAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.set(sessionId, session)
|
||||||
|
this.options.logger.info(
|
||||||
|
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
|
||||||
|
"Created remote proxy session",
|
||||||
|
)
|
||||||
|
|
||||||
|
return { sessionId, windowUrl: session.bootstrapUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(sessionId: string): Promise<boolean> {
|
||||||
|
return this.disposeSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupExpiredSessions() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const session of Array.from(this.sessions.values())) {
|
||||||
|
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await this.disposeSession(session.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disposeSession(sessionId: string): Promise<boolean> {
|
||||||
|
const session = this.sessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.delete(sessionId)
|
||||||
|
session.dispatcher?.close().catch(() => {})
|
||||||
|
await session.app.close().catch(() => {})
|
||||||
|
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(input: string): URL {
|
||||||
|
const parsed = new URL(input.trim())
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("Server URL must use http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.hash = ""
|
||||||
|
parsed.search = ""
|
||||||
|
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReturnToTarget(entryUrl: URL): string {
|
||||||
|
const query = entryUrl.search ? entryUrl.search : ""
|
||||||
|
return `${entryUrl.pathname || "/"}${query}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBootstrapPageHtml(): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||||
|
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
|
||||||
|
h1 { font-size: 18px; margin: 0 0 12px; }
|
||||||
|
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
|
||||||
|
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Connecting...</h1>
|
||||||
|
<p>Finalizing local authentication.</p>
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const returnTo = sanitizeReturnTo(params.get("returnTo"))
|
||||||
|
const errorEl = document.getElementById("error")
|
||||||
|
|
||||||
|
function sanitizeReturnTo(value) {
|
||||||
|
if (!value || typeof value !== "string") return "/"
|
||||||
|
if (!value.startsWith("/")) return "/"
|
||||||
|
if (value.startsWith("//")) return "/"
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorEl.textContent = message
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!token) {
|
||||||
|
showError("Missing bootstrap token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || "Token exchange failed (" + res.status + ")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace(returnTo)
|
||||||
|
} catch (error) {
|
||||||
|
showError(error && error.message ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenBody(body: unknown): { token: string } {
|
||||||
|
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
|
||||||
|
const token = typeof value?.token === "string" ? value.token.trim() : ""
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Missing bootstrap token")
|
||||||
|
}
|
||||||
|
return { token }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJsonBody(body: unknown): unknown {
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
return JSON.parse(body.toString("utf-8"))
|
||||||
|
}
|
||||||
|
if (typeof body === "string") {
|
||||||
|
return JSON.parse(body)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequestBody(body: unknown): any {
|
||||||
|
if (body == null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return JSON.stringify(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyRequest(args: {
|
||||||
|
request: FastifyRequest
|
||||||
|
reply: FastifyReply
|
||||||
|
session: RemoteProxySession
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
const { request, reply, session, logger } = args
|
||||||
|
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
|
||||||
|
const headers = filterRequestHeaders(request.headers, session)
|
||||||
|
|
||||||
|
const init: any = {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
dispatcher: session.dispatcher,
|
||||||
|
redirect: "manual",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
|
const body = toRequestBody(request.body)
|
||||||
|
if (body !== undefined) {
|
||||||
|
init.body = body
|
||||||
|
init.duplex = "half"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(upstreamUrl, init as any)
|
||||||
|
reply.code(response.status)
|
||||||
|
applyResponseHeaders(reply, response, session)
|
||||||
|
|
||||||
|
if (!response.body || request.method === "HEAD") {
|
||||||
|
reply.send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.hijack()
|
||||||
|
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
|
||||||
|
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
|
||||||
|
if (!reply.sent) {
|
||||||
|
reply.code(502).send({ error: "Remote proxy request failed" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
|
||||||
|
const parsed = new URL(rawUrl, "https://localhost")
|
||||||
|
const url = new URL(baseUrl.toString())
|
||||||
|
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
|
||||||
|
url.search = stripInternalQuery(parsed.search)
|
||||||
|
url.hash = ""
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
|
||||||
|
const basePath = normalizedBasePath(baseUrl)
|
||||||
|
if (basePath === "/") {
|
||||||
|
return requestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestPath === "/") {
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathHasBasePrefix(basePath, requestPath)) {
|
||||||
|
return requestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${basePath}${requestPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedBasePath(baseUrl: URL): string {
|
||||||
|
return baseUrl.pathname || "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
|
||||||
|
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripInternalQuery(search: string): string {
|
||||||
|
if (!search || search === "?") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRequestHeaders(
|
||||||
|
headers: FastifyRequest["headers"],
|
||||||
|
session: RemoteProxySession,
|
||||||
|
): Record<string, string> {
|
||||||
|
const next: Record<string, string> = {}
|
||||||
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||||
|
if (!value) continue
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (
|
||||||
|
isHopByHopHeader(lower) ||
|
||||||
|
lower === "host" ||
|
||||||
|
lower === "content-length" ||
|
||||||
|
lower === "accept-encoding"
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lower === "origin") {
|
||||||
|
next[key] = session.targetBaseUrl.origin
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lower === "referer") {
|
||||||
|
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
|
||||||
|
if (rewritten) {
|
||||||
|
next[key] = rewritten
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lower === "cookie") {
|
||||||
|
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
|
||||||
|
if (rewritten) {
|
||||||
|
next[key] = rewritten
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next[key] = Array.isArray(value) ? value.join(",") : value
|
||||||
|
}
|
||||||
|
|
||||||
|
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
|
||||||
|
if (!next.origin) {
|
||||||
|
next.origin = session.targetBaseUrl.origin
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
|
||||||
|
if (!referer) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(referer)
|
||||||
|
const rewritten = new URL(targetBaseUrl.toString())
|
||||||
|
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
|
||||||
|
rewritten.search = parsed.search
|
||||||
|
rewritten.hash = parsed.hash
|
||||||
|
return rewritten.toString()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
|
||||||
|
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
|
||||||
|
if (Array.isArray(setCookie)) {
|
||||||
|
for (const cookie of setCookie) {
|
||||||
|
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers.forEach((value: string, key: string) => {
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (
|
||||||
|
isHopByHopHeader(lower) ||
|
||||||
|
lower === "set-cookie" ||
|
||||||
|
lower === "content-length" ||
|
||||||
|
lower === "content-encoding"
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === "location") {
|
||||||
|
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
|
||||||
|
const next: Record<string, string | string[]> = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next[key] = Array.isArray(value) ? value.map(String) : String(value)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
|
||||||
|
const parts = cookie.split(";").map((part) => part.trim())
|
||||||
|
const first = parts.shift() ?? ""
|
||||||
|
const separator = first.indexOf("=")
|
||||||
|
if (separator <= 0) {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = first.slice(0, separator).trim()
|
||||||
|
const value = first.slice(separator + 1)
|
||||||
|
const rewritten = [`${cookiePrefix}${name}=${value}`]
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rewritten.push(part)
|
||||||
|
}
|
||||||
|
return rewritten.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
|
||||||
|
const next: string[] = []
|
||||||
|
for (const rawPart of cookieHeader.split(";")) {
|
||||||
|
const part = rawPart.trim()
|
||||||
|
if (!part) continue
|
||||||
|
const separator = part.indexOf("=")
|
||||||
|
if (separator <= 0) continue
|
||||||
|
const name = part.slice(0, separator).trim()
|
||||||
|
const value = part.slice(separator + 1)
|
||||||
|
if (!name.startsWith(cookiePrefix)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
|
||||||
|
}
|
||||||
|
return next.join("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(location, targetBaseUrl)
|
||||||
|
if (parsed.origin !== targetBaseUrl.origin) {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewritten = new URL(localBaseUrl.toString())
|
||||||
|
rewritten.pathname = parsed.pathname
|
||||||
|
rewritten.search = parsed.search
|
||||||
|
rewritten.hash = parsed.hash
|
||||||
|
return rewritten.toString()
|
||||||
|
} catch {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHopByHopHeader(name: string): boolean {
|
||||||
|
return new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
]).has(name)
|
||||||
|
}
|
||||||
@@ -119,7 +119,8 @@
|
|||||||
showError(message || `Login failed (${res.status})`)
|
showError(message || `Login failed (${res.status})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.location.href = "/"
|
// Replace history entry so Back doesn't return to /login.
|
||||||
|
window.location.replace("/")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e && e.message ? e.message : String(e))
|
showError(e && e.message ? e.message : String(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,19 @@ function getTokenHtml(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/login", async (_request, reply) => {
|
app.get("/login", async (request, reply) => {
|
||||||
|
// If already authenticated, don't show the login page.
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (session) {
|
||||||
|
reply.redirect("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid caching the login page (helps with bfcache/back behavior).
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
reply.header("Pragma", "no-cache")
|
||||||
|
reply.header("Expires", "0")
|
||||||
|
|
||||||
const status = deps.authManager.getStatus()
|
const status = deps.authManager.getStatus()
|
||||||
reply.type("text/html").send(getLoginHtml(status.username))
|
reply.type("text/html").send(getLoginHtml(status.username))
|
||||||
})
|
})
|
||||||
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid caching the token bootstrap page.
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
reply.header("Pragma", "no-cache")
|
||||||
|
reply.header("Expires", "0")
|
||||||
|
|
||||||
reply.type("text/html").send(getTokenHtml())
|
reply.type("text/html").send(getTokenHtml())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ interface RouteDeps {
|
|||||||
const StartSchema = z.object({
|
const StartSchema = z.object({
|
||||||
title: z.string().trim().min(1),
|
title: z.string().trim().min(1),
|
||||||
command: z.string().trim().min(1),
|
command: z.string().trim().min(1),
|
||||||
|
notify: z.boolean().optional(),
|
||||||
|
notification: z
|
||||||
|
.object({
|
||||||
|
sessionID: z.string().trim().min(1),
|
||||||
|
directory: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.notify && !value.notification) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Notification metadata is required when notify is enabled",
|
||||||
|
path: ["notification"],
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const OutputQuerySchema = z.object({
|
const OutputQuerySchema = z.object({
|
||||||
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
|
|||||||
|
|
||||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||||
const payload = StartSchema.parse(request.body ?? {})
|
const payload = StartSchema.parse(request.body ?? {})
|
||||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
|
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
|
||||||
|
notify: payload.notify,
|
||||||
|
notification: payload.notification,
|
||||||
|
})
|
||||||
reply.code(201)
|
reply.code(201)
|
||||||
return process
|
return process
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { ConfigStore } from "../../config/store"
|
|
||||||
import { BinaryRegistry } from "../../config/binaries"
|
|
||||||
|
|
||||||
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, reply) => {
|
|
||||||
// Backwards compatible: treat PUT as a merge-patch update.
|
|
||||||
try {
|
|
||||||
deps.configStore.mergePatch(request.body ?? {})
|
|
||||||
return deps.configStore.get()
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch("/api/config/app", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
deps.configStore.mergePatch(request.body ?? {})
|
|
||||||
return deps.configStore.get()
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
import { EventBus } from "../../events/bus"
|
import { EventBus } from "../../events/bus"
|
||||||
import { WorkspaceEventPayload } from "../../api-types"
|
import { WorkspaceEventPayload } from "../../api-types"
|
||||||
|
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||||
import { Logger } from "../../logger"
|
import { Logger } from "../../logger"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
registerClient: (cleanup: () => void) => () => void
|
registerClient: (cleanup: () => void) => () => void
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
connectionManager: ClientConnectionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextClientId = 0
|
let nextClientId = 0
|
||||||
|
|
||||||
|
const ConnectionQuerySchema = z.object({
|
||||||
|
clientId: z.string().trim().min(1),
|
||||||
|
connectionId: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PongBodySchema = ConnectionQuerySchema.extend({
|
||||||
|
pingTs: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/events", (request, reply) => {
|
app.get("/api/events", (request, reply) => {
|
||||||
const clientId = ++nextClientId
|
const clientId = ++nextClientId
|
||||||
|
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||||
deps.logger.debug({ clientId }, "SSE client connected")
|
deps.logger.debug({ clientId }, "SSE client connected")
|
||||||
|
|
||||||
const origin = request.headers.origin ?? "*"
|
const origin = request.headers.origin ?? "*"
|
||||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
|
|
||||||
const unsubscribe = deps.eventBus.onEvent(send)
|
const unsubscribe = deps.eventBus.onEvent(send)
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
const ping = { ts: Date.now() }
|
||||||
|
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unregister = deps.registerClient(close)
|
const unregister = deps.registerClient(close)
|
||||||
|
const unregisterConnection = deps.connectionManager.register({
|
||||||
|
...connection,
|
||||||
|
close,
|
||||||
|
})
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
close()
|
close()
|
||||||
unregister()
|
unregister()
|
||||||
|
unregisterConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
request.raw.on("close", handleClose)
|
request.raw.on("close", handleClose)
|
||||||
request.raw.on("error", handleClose)
|
request.raw.on("error", handleClose)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post("/api/client-connections/pong", (request, reply) => {
|
||||||
|
const body = PongBodySchema.parse(request.body ?? {})
|
||||||
|
if (!deps.connectionManager.pong(body)) {
|
||||||
|
reply.code(404).send({ error: "Client connection not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.code(204).send()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { ServerMeta } from "../../api-types"
|
import { ServerMeta } from "../../api-types"
|
||||||
import { resolveNetworkAddresses } from "../network-addresses"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const localPort = resolveLocalPort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const remote = resolveRemote(meta)
|
const remote = resolveRemote(meta)
|
||||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
localPort,
|
localPort,
|
||||||
remotePort: remote?.port,
|
remotePort: remote?.port,
|
||||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import type { VoiceModeStateResponse } from "../../api-types"
|
||||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||||
import type { EventBus } from "../../events/bus"
|
import type { EventBus } from "../../events/bus"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import { PluginChannelManager } from "../../plugins/channel"
|
import { PluginChannelManager } from "../../plugins/channel"
|
||||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||||
|
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
channel: PluginChannelManager
|
||||||
|
voiceModeManager: VoiceModeManager
|
||||||
}
|
}
|
||||||
|
|
||||||
const PluginEventSchema = z.object({
|
const PluginEventSchema = z.object({
|
||||||
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
|
|||||||
properties: z.record(z.unknown()).optional(),
|
properties: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
const VoiceModeStateSchema = z.object({
|
||||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
enabled: z.boolean(),
|
||||||
|
clientId: z.string().trim().min(1),
|
||||||
|
connectionId: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||||
const workspace = deps.workspaceManager.get(request.params.id)
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
reply.raw.flushHeaders?.()
|
reply.raw.flushHeaders?.()
|
||||||
reply.hijack()
|
reply.hijack()
|
||||||
|
|
||||||
const registration = channel.register(request.params.id, reply)
|
const registration = deps.channel.register(request.params.id, reply)
|
||||||
|
deps.voiceModeManager.syncInstance(request.params.id)
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
channel.send(request.params.id, buildPingEvent())
|
deps.channel.send(request.params.id, buildPingEvent())
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -49,6 +58,28 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
request.raw.on("error", close)
|
request.raw.on("error", close)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404).send({ error: "Workspace not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||||
|
const applied = deps.voiceModeManager.setEnabled(
|
||||||
|
request.params.id,
|
||||||
|
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||||
|
payload.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (payload.enabled && !applied) {
|
||||||
|
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enabled: payload.enabled }
|
||||||
|
})
|
||||||
|
|
||||||
const handleWildcard = async (request: any, reply: any) => {
|
const handleWildcard = async (request: any, reply: any) => {
|
||||||
const workspaceId = request.params.id as string
|
const workspaceId = request.params.id as string
|
||||||
const workspace = deps.workspaceManager.get(workspaceId)
|
const workspace = deps.workspaceManager.get(workspaceId)
|
||||||
|
|||||||
54
packages/server/src/server/routes/remote-proxy.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { RemoteProxySessionCreateResponse } from "../../api-types"
|
||||||
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { RemoteProxySessionManager } from "../remote-proxy"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
logger: Logger
|
||||||
|
sessionManager: RemoteProxySessionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateSessionSchema = z.object({
|
||||||
|
baseUrl: z.string().min(1),
|
||||||
|
skipTlsVerify: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SessionParamsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
|
||||||
|
try {
|
||||||
|
const body = CreateSessionSchema.parse(request.body ?? {})
|
||||||
|
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = SessionParamsSchema.parse(request.params ?? {})
|
||||||
|
const deleted = await deps.sessionManager.deleteSession(params.id)
|
||||||
|
if (!deleted) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Remote proxy session not found" }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProbeSchema = z.object({
|
||||||
|
baseUrl: z.string().min(1),
|
||||||
|
skipTlsVerify: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
|
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = ProbeSchema.parse(request.body ?? {})
|
||||||
|
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||||
|
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||||
|
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(probeUrl, {
|
||||||
|
method: "GET",
|
||||||
|
dispatcher,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: `Remote server returned HTTP ${response.status}`,
|
||||||
|
errorCode: "http_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { authenticated?: unknown }
|
||||||
|
if (typeof payload?.authenticated !== "boolean") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: "Remote server did not return a valid CodeNomad auth response",
|
||||||
|
errorCode: "invalid_server",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reachable: true,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: !payload.authenticated,
|
||||||
|
authenticated: payload.authenticated,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = describeProbeError(error)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reachable: false,
|
||||||
|
normalizedUrl,
|
||||||
|
skipTlsVerify,
|
||||||
|
requiresAuth: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: message.message,
|
||||||
|
errorCode: message.code,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
await dispatcher?.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(input: string): string {
|
||||||
|
const parsed = new URL(input.trim())
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("Server URL must use http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.hash = ""
|
||||||
|
parsed.search = ""
|
||||||
|
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||||
|
const value = parsed.toString()
|
||||||
|
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||||
|
const chain = unwrapErrorChain(error)
|
||||||
|
const detailed =
|
||||||
|
chain.find((entry) => {
|
||||||
|
const code = (entry?.code ?? "").toString()
|
||||||
|
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||||
|
}) ?? chain[0]
|
||||||
|
|
||||||
|
const code = (detailed?.code ?? "").toString()
|
||||||
|
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||||
|
|
||||||
|
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||||
|
return {
|
||||||
|
code: "tls_error",
|
||||||
|
message: "Certificate check failed while connecting to the remote server.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code:
|
||||||
|
code === "ERR_INVALID_URL"
|
||||||
|
? "invalid_url"
|
||||||
|
: code === "ECONNREFUSED"
|
||||||
|
? "connection_refused"
|
||||||
|
: code === "ENOTFOUND"
|
||||||
|
? "dns_error"
|
||||||
|
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||||
|
? "timeout"
|
||||||
|
: code
|
||||||
|
? code.toLowerCase()
|
||||||
|
: "probe_failed",
|
||||||
|
message: exactMessage || "Failed to connect to the remote server.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||||
|
const results: Array<{ code?: unknown; message?: string }> = []
|
||||||
|
let current: unknown = error
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
|
||||||
|
while (current && typeof current === "object" && !seen.has(current)) {
|
||||||
|
seen.add(current)
|
||||||
|
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||||
|
results.push({ code: entry.code, message: entry.message })
|
||||||
|
current = entry.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0 && error instanceof Error) {
|
||||||
|
results.push({ message: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
84
packages/server/src/server/routes/settings.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||||
|
import type { SettingsService } from "../../settings/service"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
settings: SettingsService
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidateBinarySchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
|
||||||
|
const result = probeBinaryVersion(binaryPath)
|
||||||
|
return { valid: result.valid, version: result.version, error: result.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
// Full-document access
|
||||||
|
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
||||||
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||||
|
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return sanitizeConfigOwner(
|
||||||
|
request.params.owner,
|
||||||
|
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
|
||||||
|
app.patch("/api/storage/state", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchDoc("state", request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
|
||||||
|
return deps.settings.getOwner("state", request.params.owner)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Binary validation helper (used by UI when adding binaries)
|
||||||
|
app.post("/api/storage/binaries/validate", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = ValidateBinarySchema.parse(request.body ?? {})
|
||||||
|
return validateBinaryPath(body.path)
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to validate binary")
|
||||||
|
reply.code(400)
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
56
packages/server/src/server/routes/sidecars.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SideCarManager } from "../../sidecars/manager"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
sidecarManager: SideCarManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideCarCreateSchema = z.object({
|
||||||
|
kind: z.literal("port").default("port"),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
insecure: z.boolean().default(false),
|
||||||
|
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "At least one field is required",
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/sidecars", async () => {
|
||||||
|
return { sidecars: await deps.sidecarManager.list() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/sidecars", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarCreateSchema.parse(request.body ?? {})
|
||||||
|
const sidecar = await deps.sidecarManager.create(body)
|
||||||
|
reply.code(201)
|
||||||
|
return sidecar
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SideCarUpdateSchema.parse(request.body ?? {})
|
||||||
|
return await deps.sidecarManager.update(request.params.id, body)
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||||
|
const removed = await deps.sidecarManager.delete(request.params.id)
|
||||||
|
if (!removed) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "SideCar not found" }
|
||||||
|
}
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
}
|
||||||
74
packages/server/src/server/routes/speech.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SpeechService } from "../../speech/service"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
speechService: SpeechService
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscribeBodySchema = z.object({
|
||||||
|
audioBase64: z.string().min(1, "Audio payload is required"),
|
||||||
|
mimeType: z.string().min(1, "Audio MIME type is required"),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SynthesizeBodySchema = z.object({
|
||||||
|
text: z.string().trim().min(1, "Text is required"),
|
||||||
|
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function getSpeechErrorStatus(error: unknown): number {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
if (error instanceof Error && /not configured/i.test(error.message)) {
|
||||||
|
return 503
|
||||||
|
}
|
||||||
|
return 502
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeechErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error ? error.message : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
|
||||||
|
|
||||||
|
app.post("/api/speech/transcribe", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = TranscribeBodySchema.parse(request.body ?? {})
|
||||||
|
return await deps.speechService.transcribe(body)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to transcribe audio")
|
||||||
|
reply.code(getSpeechErrorStatus(error))
|
||||||
|
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/speech/synthesize", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||||
|
return await deps.speechService.synthesize(body)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to synthesize audio")
|
||||||
|
reply.code(getSpeechErrorStatus(error))
|
||||||
|
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/speech/synthesize/stream", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||||
|
const result = await deps.speechService.synthesizeStream(body)
|
||||||
|
reply.header("Content-Type", result.mimeType)
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
return reply.send(result.stream)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to stream synthesized audio")
|
||||||
|
reply.code(getSpeechErrorStatus(error))
|
||||||
|
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { FastifyInstance, FastifyReply } from "fastify"
|
import { FastifyInstance, FastifyReply } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { WorkspaceManager } from "../../workspaces/manager"
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
|
||||||
|
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
|
||||||
|
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
|
||||||
|
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
@@ -19,6 +23,24 @@ const WorkspaceFileContentQuerySchema = z.object({
|
|||||||
path: z.string(),
|
path: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WorkspaceFileContentBodySchema = z.object({
|
||||||
|
contents: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeGitDiffQuerySchema = z.object({
|
||||||
|
path: z.string().trim().min(1, "Path is required"),
|
||||||
|
originalPath: z.string().trim().optional(),
|
||||||
|
scope: z.enum(["staged", "unstaged"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeGitPathsBodySchema = z.object({
|
||||||
|
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorktreeGitCommitBodySchema = z.object({
|
||||||
|
message: z.string().trim().min(1, "Commit message is required"),
|
||||||
|
})
|
||||||
|
|
||||||
const WorkspaceFileSearchQuerySchema = z.object({
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, "Query is required"),
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
@@ -100,10 +122,152 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return handleWorkspaceError(error, reply)
|
return handleWorkspaceError(error, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string }
|
||||||
|
Querystring: { path?: string }
|
||||||
|
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||||
|
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||||
|
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
return await getWorktreeGitDiff({
|
||||||
|
workspaceFolder: directory,
|
||||||
|
path: query.path,
|
||||||
|
originalPath: query.originalPath,
|
||||||
|
scope: query.scope,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Body: { paths: string[] }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
|
||||||
|
return { ok: true as const }
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Body: { paths: string[] }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
|
||||||
|
return { ok: true as const }
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Params: { id: string; slug: string }
|
||||||
|
Body: { message: string }
|
||||||
|
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
|
||||||
|
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
|
||||||
|
return { ok: true as const, ...result }
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGitWorktreeDirectory(
|
||||||
|
workspaceManager: WorkspaceManager,
|
||||||
|
workspaceId: string,
|
||||||
|
worktreeSlug: string,
|
||||||
|
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const workspace = workspaceManager.get(workspaceId)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404)
|
||||||
|
reply.send({ error: "Workspace not found" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitAvailable = await isGitAvailable(workspace.path)
|
||||||
|
if (!gitAvailable) {
|
||||||
|
reply.code(503)
|
||||||
|
reply.send({ error: "Git is not installed or not available in PATH" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
|
||||||
|
if (!isGitRepo) {
|
||||||
|
reply.code(400)
|
||||||
|
reply.send({ error: "Workspace is not a Git repository" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = await resolveWorktreeDirectory({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
workspacePath: workspace.path,
|
||||||
|
worktreeSlug,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404)
|
||||||
|
reply.send({ error: "Worktree not found" })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||||
|
if (isGitMutationError(error)) {
|
||||||
|
reply.code(error.statusCode)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
if (error instanceof Error && error.message === "Workspace not found") {
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
reply.code(404)
|
reply.code(404)
|
||||||
return { error: "Workspace not found" }
|
return { error: "Workspace not found" }
|
||||||
|
|||||||
55
packages/server/src/settings/binaries.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { SettingsService } from "./service"
|
||||||
|
|
||||||
|
export interface OpenCodeBinaryEntry {
|
||||||
|
path: string
|
||||||
|
version?: string
|
||||||
|
lastUsed?: number
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedBinary {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyLabel(p: string): string {
|
||||||
|
const parts = p.split(/[\\/]/)
|
||||||
|
const last = parts[parts.length - 1] || p
|
||||||
|
return last || p
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
|
||||||
|
const ui = settings.getOwner("state", "ui")
|
||||||
|
const list = (ui as any)?.opencodeBinaries
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
|
||||||
|
const server = settings.getOwner("config", "server")
|
||||||
|
const value = (server as any)?.opencodeBinary
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BinaryResolver {
|
||||||
|
constructor(private readonly settings: SettingsService) {}
|
||||||
|
|
||||||
|
list(): OpenCodeBinaryEntry[] {
|
||||||
|
return readUiBinaries(this.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDefault(): ResolvedBinary {
|
||||||
|
const binaries = this.list()
|
||||||
|
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||||
|
const fallback = binaries[0]?.path
|
||||||
|
const path = configuredDefault ?? fallback ?? "opencode"
|
||||||
|
|
||||||
|
const entry = binaries.find((b) => b.path === path)
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
label: entry?.label ?? prettyLabel(path),
|
||||||
|
version: entry?.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/server/src/settings/merge-patch.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
type PlainObject = Record<string, unknown>
|
||||||
|
|
||||||
|
export function isPlainObject(value: unknown): value is PlainObject {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const proto = Object.getPrototypeOf(value)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 7396-ish merge patch with explicit null deletes.
|
||||||
|
* - Objects merge recursively
|
||||||
|
* - Arrays/scalars replace
|
||||||
|
* - null deletes keys
|
||||||
|
*/
|
||||||
|
export function applyMergePatch(current: unknown, patch: unknown): unknown {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = base[key]
|
||||||
|
if (isPlainObject(value) && isPlainObject(existing)) {
|
||||||
|
base[key] = applyMergePatch(existing, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
274
packages/server/src/settings/migrate.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { isPlainObject } from "./merge-patch"
|
||||||
|
|
||||||
|
type Doc = Record<string, unknown>
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadYaml(filePath: string, logger: Logger): unknown {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
return parseYaml(content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadJson(filePath: string, logger: Logger): unknown {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||||
|
const yaml = stringifyYaml(doc as any)
|
||||||
|
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBackupPath(filePath: string): string {
|
||||||
|
const preferred = `${filePath}.bak`
|
||||||
|
if (!fs.existsSync(preferred)) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
return `${filePath}.bak.${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(value: unknown): Doc {
|
||||||
|
return isPlainObject(value) ? (value as Doc) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeNewOwnerDoc(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
// Heuristic: owner-bucket docs have at least one of these roots.
|
||||||
|
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyConfig(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyState(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
return Boolean(doc.recentFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitKeys(source: Doc, keys: Set<string>): Doc {
|
||||||
|
const out: Doc = {}
|
||||||
|
for (const [k, v] of Object.entries(source)) {
|
||||||
|
if (keys.has(k)) continue
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
|
||||||
|
const cfg = normalizeDoc(legacyConfig)
|
||||||
|
const st = normalizeDoc(legacyState)
|
||||||
|
|
||||||
|
const outConfig: Doc = {}
|
||||||
|
const outState: Doc = {}
|
||||||
|
|
||||||
|
const uiConfig: Doc = {}
|
||||||
|
const uiSettings: Doc = {}
|
||||||
|
const serverConfig: Doc = {}
|
||||||
|
const uiState: Doc = {}
|
||||||
|
|
||||||
|
// theme -> config.ui.theme
|
||||||
|
if (typeof cfg.theme === "string") {
|
||||||
|
uiConfig.theme = cfg.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = normalizeDoc(cfg.preferences)
|
||||||
|
if (Object.keys(preferences).length > 0) {
|
||||||
|
// Server-owned stable keys
|
||||||
|
const envVars = preferences.environmentVariables
|
||||||
|
if (isPlainObject(envVars)) {
|
||||||
|
serverConfig.environmentVariables = envVars
|
||||||
|
}
|
||||||
|
const listeningMode = preferences.listeningMode
|
||||||
|
if (typeof listeningMode === "string") {
|
||||||
|
serverConfig.listeningMode = listeningMode
|
||||||
|
}
|
||||||
|
const logLevel = preferences.logLevel
|
||||||
|
if (typeof logLevel === "string") {
|
||||||
|
serverConfig.logLevel = logLevel
|
||||||
|
}
|
||||||
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
|
if (typeof lastUsedBinary === "string") {
|
||||||
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI-owned state keys (drop preferences)
|
||||||
|
const modelRecents = preferences.modelRecents
|
||||||
|
const modelFavorites = preferences.modelFavorites
|
||||||
|
const modelThinkingSelections = preferences.modelThinkingSelections
|
||||||
|
|
||||||
|
const models: Doc = {}
|
||||||
|
if (Array.isArray(modelRecents)) {
|
||||||
|
models.recents = modelRecents
|
||||||
|
}
|
||||||
|
if (Array.isArray(modelFavorites)) {
|
||||||
|
models.favorites = modelFavorites
|
||||||
|
}
|
||||||
|
if (isPlainObject(modelThinkingSelections)) {
|
||||||
|
models.thinkingSelections = modelThinkingSelections
|
||||||
|
}
|
||||||
|
if (Object.keys(models).length > 0) {
|
||||||
|
uiState.models = models
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining preferences are treated as stable UI settings.
|
||||||
|
const moved = new Set([
|
||||||
|
"environmentVariables",
|
||||||
|
"listeningMode",
|
||||||
|
"logLevel",
|
||||||
|
"lastUsedBinary",
|
||||||
|
"modelRecents",
|
||||||
|
"modelFavorites",
|
||||||
|
"modelThinkingSelections",
|
||||||
|
])
|
||||||
|
Object.assign(uiSettings, omitKeys(preferences, moved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentFolders lives in legacy state (yaml) or legacy config.json
|
||||||
|
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
|
||||||
|
if (Array.isArray(recentFolders)) {
|
||||||
|
uiState.recentFolders = recentFolders
|
||||||
|
}
|
||||||
|
|
||||||
|
// opencodeBinaries -> state.ui.opencodeBinaries
|
||||||
|
if (Array.isArray(cfg.opencodeBinaries)) {
|
||||||
|
uiState.opencodeBinaries = cfg.opencodeBinaries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(uiSettings).length > 0) {
|
||||||
|
uiConfig.settings = uiSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(uiConfig).length > 0) {
|
||||||
|
outConfig.ui = uiConfig
|
||||||
|
}
|
||||||
|
if (Object.keys(serverConfig).length > 0) {
|
||||||
|
outConfig.server = serverConfig
|
||||||
|
}
|
||||||
|
if (Object.keys(uiState).length > 0) {
|
||||||
|
outState.ui = uiState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown top-level keys -> legacy.unknown
|
||||||
|
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
|
||||||
|
const unknownConfig = omitKeys(cfg, knownConfigKeys)
|
||||||
|
if (Object.keys(unknownConfig).length > 0) {
|
||||||
|
outConfig.legacy = { unknown: unknownConfig }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownStateKeys = new Set(["recentFolders"])
|
||||||
|
const unknownState = omitKeys(st, knownStateKeys)
|
||||||
|
if (Object.keys(unknownState).length > 0) {
|
||||||
|
outState.legacy = { unknown: unknownState }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: outConfig, state: outState }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate older config/state layouts into owner-bucket YAML docs.
|
||||||
|
*
|
||||||
|
* Legacy inputs supported:
|
||||||
|
* - config.yaml with { preferences, opencodeBinaries, theme }
|
||||||
|
* - state.yaml with { recentFolders }
|
||||||
|
* - legacy config.json with full ConfigFile schema
|
||||||
|
*/
|
||||||
|
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
|
||||||
|
const configYamlPath = location.configYamlPath
|
||||||
|
const stateYamlPath = location.stateYamlPath
|
||||||
|
const legacyJsonPath = location.legacyJsonPath
|
||||||
|
|
||||||
|
const configExists = fs.existsSync(configYamlPath)
|
||||||
|
const stateExists = fs.existsSync(stateYamlPath)
|
||||||
|
|
||||||
|
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
|
||||||
|
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
|
||||||
|
|
||||||
|
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
|
||||||
|
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
|
||||||
|
|
||||||
|
if (configIsNew && stateIsNew) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyJsonExists = fs.existsSync(legacyJsonPath)
|
||||||
|
|
||||||
|
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
|
||||||
|
const shouldMigrateFromJson = !configExists && legacyJsonExists
|
||||||
|
|
||||||
|
if (!hasLegacyYaml && !shouldMigrateFromJson) {
|
||||||
|
// Either fresh install or partially written docs; let stores create on first write.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
|
||||||
|
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
|
||||||
|
|
||||||
|
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(location.baseDir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup legacy files before rewriting.
|
||||||
|
if (configExists) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(configYamlPath)
|
||||||
|
fs.renameSync(configYamlPath, bak)
|
||||||
|
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateExists) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(stateYamlPath)
|
||||||
|
fs.renameSync(stateYamlPath, bak)
|
||||||
|
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMigrateFromJson) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(legacyJsonPath)
|
||||||
|
fs.renameSync(legacyJsonPath, bak)
|
||||||
|
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeYaml(configYamlPath, config, logger)
|
||||||
|
writeYaml(stateYamlPath, state, logger)
|
||||||
|
|
||||||
|
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
|
||||||
|
}
|
||||||
40
packages/server/src/settings/public-config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { SettingsDoc } from "./yaml-doc-store"
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
|
||||||
|
|
||||||
|
if (!speech) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
|
||||||
|
if (rawApiKey) {
|
||||||
|
delete speech.apiKey
|
||||||
|
speech.hasApiKey = true
|
||||||
|
} else if (!("hasApiKey" in speech)) {
|
||||||
|
speech.hasApiKey = false
|
||||||
|
}
|
||||||
|
|
||||||
|
next.speech = speech
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
|
||||||
|
if (owner !== "server") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return sanitizeServerOwner(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
if (isPlainObject(next.server)) {
|
||||||
|
next.server = sanitizeServerOwner(next.server)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
128
packages/server/src/settings/service.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
|
import { sanitizeConfigOwner } from "./public-config"
|
||||||
|
|
||||||
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
const CanonicalLogLevelSchema = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||||
|
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||||
|
if (parsedLogLevel.success) {
|
||||||
|
next.logLevel = parsedLogLevel.data
|
||||||
|
} else if (next.logLevel !== undefined) {
|
||||||
|
next.logLevel = "DEBUG"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||||
|
if (!isPlainObject(doc)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(doc.server)) {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SettingsService {
|
||||||
|
private readonly configStore: YamlDocStore
|
||||||
|
private readonly stateStore: YamlDocStore
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly location: ConfigLocation,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {
|
||||||
|
migrateSettingsLayout(location, logger)
|
||||||
|
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||||
|
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
getDoc(kind: DocKind): SettingsDoc {
|
||||||
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.configStore.get()
|
||||||
|
const normalized = normalizeConfigDoc(current)
|
||||||
|
if (!isDeepEqual(current, normalized)) {
|
||||||
|
this.configStore.replace(normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
|
const updated =
|
||||||
|
kind === "config"
|
||||||
|
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||||
|
: this.stateStore.mergePatch(patch)
|
||||||
|
this.publish(kind, "*")
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
|
if (kind !== "config") {
|
||||||
|
return this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
return owner === "server"
|
||||||
|
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||||
|
: this.getDoc("config")[owner] as SettingsDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
|
const updated =
|
||||||
|
kind === "config"
|
||||||
|
? owner === "server"
|
||||||
|
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||||
|
: this.configStore.mergePatchOwner(owner, patch)
|
||||||
|
: this.stateStore.mergePatchOwner(owner, patch)
|
||||||
|
this.publish(kind, owner, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||||
|
if (!this.eventBus) return
|
||||||
|
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||||
|
const nextValue = value ?? this.getOwner(kind, owner)
|
||||||
|
const payload: WorkspaceEventPayload = {
|
||||||
|
type,
|
||||||
|
owner,
|
||||||
|
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||||
|
} as any
|
||||||
|
this.eventBus.publish(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||||
|
|
||||||
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(input: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YamlDocStore {
|
||||||
|
private cache: SettingsDoc = {}
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly filePath: string,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): SettingsDoc {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.filePath)) {
|
||||||
|
this.cache = {}
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||||
|
const parsed = parseYaml(content)
|
||||||
|
this.cache = normalizeDoc(parsed)
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
|
||||||
|
this.cache = {}
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): SettingsDoc {
|
||||||
|
return this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(next: unknown): SettingsDoc {
|
||||||
|
const normalized = normalizeDoc(next)
|
||||||
|
this.cache = normalized
|
||||||
|
this.loaded = true
|
||||||
|
this.persist()
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatch(patch: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
throw new Error("Patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const current = this.get()
|
||||||
|
const next = applyMergePatch(current, patch)
|
||||||
|
return this.replace(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(owner: string): SettingsDoc {
|
||||||
|
const doc = this.get()
|
||||||
|
const value = (doc as any)?.[owner]
|
||||||
|
return normalizeDoc(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceOwner(owner: string, value: unknown): SettingsDoc {
|
||||||
|
const doc = this.get()
|
||||||
|
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
|
||||||
|
this.replace(nextDoc)
|
||||||
|
return nextDoc[owner] as SettingsDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
throw new Error("Patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const doc = this.get()
|
||||||
|
const currentOwner = normalizeDoc((doc as any)?.[owner])
|
||||||
|
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
|
||||||
|
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
|
||||||
|
this.replace(nextDoc)
|
||||||
|
return nextOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||||
|
const yaml = stringifyYaml(this.cache as any)
|
||||||
|
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
256
packages/server/src/sidecars/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { connect } from "net"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
|
||||||
|
|
||||||
|
interface SideCarManagerOptions {
|
||||||
|
settings: SettingsService
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarConfigRecord {
|
||||||
|
id: string
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideCarRuntimeRecord {
|
||||||
|
status: SideCarStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SideCarManager {
|
||||||
|
private readonly configs = new Map<string, SideCarConfigRecord>()
|
||||||
|
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
|
||||||
|
|
||||||
|
constructor(private readonly options: SideCarManagerOptions) {
|
||||||
|
for (const record of this.loadConfiguredSideCars()) {
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
for (const record of this.configs.values()) {
|
||||||
|
void this.refreshPortSideCar(record.id).catch((error) => {
|
||||||
|
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<SideCar[]> {
|
||||||
|
await this.refreshPortStatuses()
|
||||||
|
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<SideCar | undefined> {
|
||||||
|
if (!this.configs.has(id)) return undefined
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(this.requireConfig(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
kind: SideCarKind
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}): Promise<SideCar> {
|
||||||
|
const normalizedName = input.name.trim()
|
||||||
|
const id = this.buildSideCarId(normalizedName)
|
||||||
|
if (this.configs.has(id)) {
|
||||||
|
throw new Error(`SideCar '${id}' already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const record: SideCarConfigRecord = {
|
||||||
|
id,
|
||||||
|
kind: input.kind,
|
||||||
|
name: normalizedName,
|
||||||
|
port: input.port,
|
||||||
|
insecure: input.insecure,
|
||||||
|
prefixMode: input.prefixMode,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configs.set(record.id, record)
|
||||||
|
this.runtime.set(record.id, { status: "stopped" })
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(record.id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: Partial<{
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
insecure: boolean
|
||||||
|
prefixMode: SideCarPrefixMode
|
||||||
|
}>,
|
||||||
|
): Promise<SideCar> {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
|
||||||
|
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||||
|
record.port = typeof input.port === "number" ? input.port : record.port
|
||||||
|
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||||
|
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
this.persistConfigs()
|
||||||
|
await this.refreshPortSideCar(id)
|
||||||
|
return this.toSideCar(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return false
|
||||||
|
|
||||||
|
this.configs.delete(id)
|
||||||
|
this.runtime.delete(id)
|
||||||
|
this.persistConfigs()
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
|
||||||
|
const protocol = sidecar.insecure ? "http" : "https"
|
||||||
|
return `${protocol}://127.0.0.1:${sidecar.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildProxyBasePath(id: string): string {
|
||||||
|
return `/sidecars/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTargetPath(id: string, incomingPath: string, search = ""): string {
|
||||||
|
const record = this.requireConfig(id)
|
||||||
|
const publicBase = this.buildProxyBasePath(id)
|
||||||
|
const normalizedPath = incomingPath || publicBase
|
||||||
|
|
||||||
|
if (record.prefixMode === "preserve") {
|
||||||
|
return `${normalizedPath}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
|
||||||
|
if (!stripped || stripped === "/") {
|
||||||
|
stripped = "/"
|
||||||
|
} else if (!stripped.startsWith("/")) {
|
||||||
|
stripped = `/${stripped}`
|
||||||
|
}
|
||||||
|
return `${stripped}${search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortStatuses() {
|
||||||
|
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPortSideCar(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
const isAvailable = await this.isPortAvailable(record.port)
|
||||||
|
const current = this.runtime.get(id)
|
||||||
|
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
|
||||||
|
if (current?.status === nextStatus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtime.set(id, { status: nextStatus })
|
||||||
|
record.updatedAt = new Date().toISOString()
|
||||||
|
this.publish(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(id: string) {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) return
|
||||||
|
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSideCar(record: SideCarConfigRecord): SideCar {
|
||||||
|
const runtime = this.runtime.get(record.id)
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
kind: record.kind,
|
||||||
|
name: record.name,
|
||||||
|
port: record.port,
|
||||||
|
insecure: record.insecure,
|
||||||
|
prefixMode: record.prefixMode,
|
||||||
|
status: runtime?.status ?? "stopped",
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireConfig(id: string): SideCarConfigRecord {
|
||||||
|
const record = this.configs.get(id)
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("SideCar not found")
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistConfigs() {
|
||||||
|
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
|
||||||
|
this.options.settings.mergePatchOwner("config", "server", { sidecars })
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfiguredSideCars(): SideCarConfigRecord[] {
|
||||||
|
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
|
||||||
|
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
|
||||||
|
const records: SideCarConfigRecord[] = []
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
const kind = record.kind === "port" ? "port" : null
|
||||||
|
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
|
||||||
|
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
|
||||||
|
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
|
||||||
|
if (!kind || !id || !name || !port) continue
|
||||||
|
|
||||||
|
const insecure = record.insecure === true
|
||||||
|
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
|
||||||
|
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
|
||||||
|
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
|
||||||
|
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||||
|
socket.end()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
socket.once("error", () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSideCarId(name: string): string {
|
||||||
|
const normalized = name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("SideCar name must include letters or numbers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Readable } from "node:stream"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { toFile } from "openai/uploads"
|
||||||
|
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
|
||||||
|
|
||||||
|
interface OpenAICompatibleSpeechProviderOptions {
|
||||||
|
settings: NormalizedSpeechSettings
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenAICompatibleSpeechProvider {
|
||||||
|
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
|
||||||
|
|
||||||
|
getCapabilities() {
|
||||||
|
const { settings } = this.options
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
configured: Boolean(settings.apiKey),
|
||||||
|
provider: settings.provider,
|
||||||
|
supportsStt: true,
|
||||||
|
supportsTts: true,
|
||||||
|
supportsStreamingTts: true,
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
sttModel: settings.sttModel,
|
||||||
|
ttsModel: settings.ttsModel,
|
||||||
|
ttsVoice: settings.ttsVoice,
|
||||||
|
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
||||||
|
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
|
const client = this.createClient()
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const extension = extensionForMime(input.mimeType)
|
||||||
|
const buffer = Buffer.from(input.audioBase64, "base64")
|
||||||
|
const filename = input.filename?.trim() || `prompt-input.${extension}`
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
bytes: buffer.byteLength,
|
||||||
|
language: input.language,
|
||||||
|
model: this.options.settings.sttModel,
|
||||||
|
},
|
||||||
|
"speech.transcribe",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestTranscription(client, buffer, filename, input)
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: typeof response?.text === "string" ? response.text : "",
|
||||||
|
language: typeof response?.language === "string" ? response.language : input.language,
|
||||||
|
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
|
||||||
|
segments: Array.isArray(response?.segments)
|
||||||
|
? response.segments
|
||||||
|
.filter((segment: any) => typeof segment?.text === "string")
|
||||||
|
.map((segment: any) => ({
|
||||||
|
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
|
||||||
|
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
|
||||||
|
text: String(segment.text),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestTranscription(
|
||||||
|
client: OpenAI,
|
||||||
|
buffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
input: TranscribeAudioInput,
|
||||||
|
): Promise<any> {
|
||||||
|
const baseRequest = {
|
||||||
|
model: this.options.settings.sttModel,
|
||||||
|
...(input.language ? { language: input.language } : {}),
|
||||||
|
...(input.prompt ? { prompt: input.prompt } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await toFile(buffer, filename, { type: input.mimeType })
|
||||||
|
return (await client.audio.transcriptions.create({
|
||||||
|
...baseRequest,
|
||||||
|
file,
|
||||||
|
response_format: "verbose_json" as any,
|
||||||
|
} as any)) as any
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
|
||||||
|
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
|
||||||
|
return (await client.audio.transcriptions.create({
|
||||||
|
...baseRequest,
|
||||||
|
file: retryFile,
|
||||||
|
} as any)) as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
|
const format = input.format ?? this.options.settings.ttsFormat
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
"speech.synthesize",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestSpeechAudio(input.text, format)
|
||||||
|
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
|
||||||
|
|
||||||
|
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return {
|
||||||
|
audioBase64: audioBuffer.toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||||
|
const format = input.format ?? this.options.settings.ttsFormat
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
"speech.synthesize.stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestSpeechAudio(input.text, format)
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Speech provider did not return a stream.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: Readable.fromWeb(response.body as any),
|
||||||
|
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
|
||||||
|
const { settings } = this.options
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${settings.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.ttsModel,
|
||||||
|
voice: settings.ttsVoice,
|
||||||
|
input: text,
|
||||||
|
response_format: format,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const detailedError = error as Error & {
|
||||||
|
cause?: unknown
|
||||||
|
code?: string
|
||||||
|
errno?: number | string
|
||||||
|
syscall?: string
|
||||||
|
address?: string
|
||||||
|
port?: number
|
||||||
|
}
|
||||||
|
this.options.logger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
endpoint: endpoint.toString(),
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
model: settings.ttsModel,
|
||||||
|
voice: settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
cause: detailedError.cause,
|
||||||
|
code: detailedError.code,
|
||||||
|
errno: detailedError.errno,
|
||||||
|
syscall: detailedError.syscall,
|
||||||
|
address: detailedError.address,
|
||||||
|
port: detailedError.port,
|
||||||
|
},
|
||||||
|
"speech.synthesize fetch failed",
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text()
|
||||||
|
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClient(): OpenAI {
|
||||||
|
const { settings } = this.options
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForMime(mimeType: string): string {
|
||||||
|
const normalized = mimeType.toLowerCase()
|
||||||
|
if (normalized.includes("webm")) return "webm"
|
||||||
|
if (normalized.includes("ogg")) return "ogg"
|
||||||
|
if (normalized.includes("wav")) return "wav"
|
||||||
|
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
|
||||||
|
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
|
||||||
|
return "webm"
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
|
||||||
|
if (format === "wav") return "audio/wav"
|
||||||
|
if (format === "opus") return 'audio/ogg; codecs="opus"'
|
||||||
|
if (format === "aac") return "audio/aac"
|
||||||
|
return "audio/mpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrailingSlash(value: string): string {
|
||||||
|
return value.endsWith("/") ? value : `${value}/`
|
||||||
|
}
|
||||||
106
packages/server/src/speech/service.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import type { Readable } from "node:stream"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
|
||||||
|
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
|
||||||
|
|
||||||
|
const ServerSpeechSettingsSchema = z.object({
|
||||||
|
speech: z
|
||||||
|
.object({
|
||||||
|
provider: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
sttModel: z.string().optional(),
|
||||||
|
ttsModel: z.string().optional(),
|
||||||
|
ttsVoice: z.string().optional(),
|
||||||
|
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface TranscribeAudioInput {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
filename?: string
|
||||||
|
language?: string
|
||||||
|
prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizeSpeechInput {
|
||||||
|
text: string
|
||||||
|
format?: "mp3" | "wav" | "opus" | "aac"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechSynthesisStreamResponse {
|
||||||
|
stream: Readable
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechProvider {
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse
|
||||||
|
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||||
|
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||||
|
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSpeechSettings {
|
||||||
|
provider: string
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
ttsFormat: "mp3" | "wav" | "opus" | "aac"
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDER = "openai-compatible"
|
||||||
|
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||||
|
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
|
||||||
|
const DEFAULT_TTS_VOICE = "alloy"
|
||||||
|
const DEFAULT_TTS_FORMAT = "mp3"
|
||||||
|
export class SpeechService {
|
||||||
|
constructor(
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse {
|
||||||
|
return this.createProvider().getCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
|
return this.createProvider().transcribe(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
|
return this.createProvider().synthesize(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||||
|
return this.createProvider().synthesizeStream(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createProvider(): SpeechProvider {
|
||||||
|
const settings = this.resolveSettings()
|
||||||
|
return new OpenAICompatibleSpeechProvider({
|
||||||
|
settings,
|
||||||
|
logger: this.logger.child({ provider: settings.provider }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSettings(): NormalizedSpeechSettings {
|
||||||
|
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
||||||
|
const speech = parsed.speech ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||||
|
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
||||||
|
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
|
||||||
|
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
||||||
|
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
||||||
|
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
||||||
|
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
|
|||||||
assert.equal(result.uiStaticDir, bundledDir)
|
assert.equal(result.uiStaticDir, bundledDir)
|
||||||
assert.equal(result.uiVersion, "0.8.1")
|
assert.equal(result.uiVersion, "0.8.1")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("prefers bundled when bundled and downloaded versions are equal", async () => {
|
||||||
|
const bundledDir = path.join(tempRoot, "bundled")
|
||||||
|
const configDir = path.join(tempRoot, "config")
|
||||||
|
const currentDir = path.join(configDir, "ui", "current")
|
||||||
|
|
||||||
|
await mkdir(bundledDir, { recursive: true })
|
||||||
|
await mkdir(currentDir, { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||||
|
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||||
|
|
||||||
|
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||||
|
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||||
|
|
||||||
|
const result = await resolveUi({
|
||||||
|
serverVersion: "0.8.1",
|
||||||
|
bundledUiDir: bundledDir,
|
||||||
|
autoUpdate: false,
|
||||||
|
configDir,
|
||||||
|
logger: noopLogger,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.source, "bundled")
|
||||||
|
assert.equal(result.uiStaticDir, bundledDir)
|
||||||
|
assert.equal(result.uiVersion, "0.8.1")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
|||||||
uiStaticDir: currentResolved,
|
uiStaticDir: currentResolved,
|
||||||
source: "downloaded",
|
source: "downloaded",
|
||||||
uiVersion: await readUiVersion(currentResolved),
|
uiVersion: await readUiVersion(currentResolved),
|
||||||
priority: 2,
|
priority: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
|||||||
uiStaticDir: bundledResolved,
|
uiStaticDir: bundledResolved,
|
||||||
source: "bundled",
|
source: "bundled",
|
||||||
uiVersion: await readUiVersion(bundledResolved),
|
uiVersion: await readUiVersion(bundledResolved),
|
||||||
priority: 1,
|
priority: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
|
||||||
|
|
||||||
|
describe("parseWslUncPath", () => {
|
||||||
|
it("parses WSL UNC paths into distro and linux path", () => {
|
||||||
|
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
||||||
|
distro: "Ubuntu",
|
||||||
|
linuxPath: "/home/dev/.opencode/bin/opencode",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports the legacy wsl$ UNC prefix", () => {
|
||||||
|
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
|
||||||
|
distro: "Ubuntu",
|
||||||
|
linuxPath: "/home/dev",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveWslWorkingDirectory", () => {
|
||||||
|
it("keeps WSL workspace folders in the same distro", () => {
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
|
||||||
|
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
|
||||||
|
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
|
||||||
|
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects WSL workspace folders from a different distro", () => {
|
||||||
|
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildWindowsSpawnSpec", () => {
|
||||||
|
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve", "--port", "0"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||||
|
env: {
|
||||||
|
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||||
|
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||||
|
OPENCODE_SERVER_PASSWORD: "secret",
|
||||||
|
},
|
||||||
|
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--cd",
|
||||||
|
"/home/dev/workspace",
|
||||||
|
"--exec",
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
"--port",
|
||||||
|
"0",
|
||||||
|
])
|
||||||
|
assert.equal(spec.cwd, undefined)
|
||||||
|
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("upgrades existing WSLENV path entries to include /p", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||||
|
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
||||||
|
},
|
||||||
|
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve", "--port", "0"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`C:\Users\dev\workspace`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
String.raw`C:\Users\dev\workspace`,
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
"--port",
|
||||||
|
"0",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses wslpath for UNC network workspace folders", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`\\server\share\workspace`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
String.raw`\\server\share\workspace`,
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||||
|
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
"/home/dev/workspace",
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
])
|
||||||
|
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
||||||
|
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
|
||||||
|
})
|
||||||
|
})
|
||||||
121
packages/server/src/workspaces/git-mutations.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { spawn } from "child_process"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
|
||||||
|
class GitMutationError extends Error {
|
||||||
|
statusCode: number
|
||||||
|
|
||||||
|
constructor(message: string, statusCode = 400) {
|
||||||
|
super(message)
|
||||||
|
this.name = "GitMutationError"
|
||||||
|
this.statusCode = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||||
|
let stdout = ""
|
||||||
|
let stderr = ""
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString()
|
||||||
|
})
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
child.once("error", (error) => {
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
})
|
||||||
|
child.once("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ ok: true, stdout })
|
||||||
|
} else {
|
||||||
|
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGitWorktreeRelativePath(input: string): string {
|
||||||
|
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
|
||||||
|
if (!normalized) {
|
||||||
|
throw new GitMutationError("Path is required", 400)
|
||||||
|
}
|
||||||
|
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
||||||
|
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
|
||||||
|
}
|
||||||
|
if (normalized === "." || normalized === "..") {
|
||||||
|
throw new GitMutationError(`Invalid path: ${input}`, 400)
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
|
||||||
|
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGitMutationPaths(paths: string[]): string[] {
|
||||||
|
const deduped = new Set<string>()
|
||||||
|
for (const rawPath of paths) {
|
||||||
|
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
|
||||||
|
}
|
||||||
|
const normalized = Array.from(deduped)
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new GitMutationError("At least one path is required", 400)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
|
||||||
|
const result = await resultPromise
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.stderr?.trim() || result.error.message || fallbackMessage
|
||||||
|
throw new GitMutationError(message, 409)
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGitMutationError(error: unknown): error is GitMutationError {
|
||||||
|
return error instanceof GitMutationError
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
|
||||||
|
const paths = normalizeGitMutationPaths(params.paths)
|
||||||
|
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
|
||||||
|
const paths = normalizeGitMutationPaths(params.paths)
|
||||||
|
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
|
||||||
|
if (headResult.ok) {
|
||||||
|
await ensureGitCommandSucceeded(
|
||||||
|
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
|
||||||
|
"Failed to unstage files",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureGitCommandSucceeded(
|
||||||
|
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
|
||||||
|
"Failed to unstage files",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
|
||||||
|
const message = params.message.trim()
|
||||||
|
if (!message) {
|
||||||
|
throw new GitMutationError("Commit message is required", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
|
||||||
|
|
||||||
|
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
|
||||||
|
if (!shaResult.ok) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitSha = shaResult.stdout.trim()
|
||||||
|
return commitSha ? { commitSha } : {}
|
||||||
|
}
|
||||||
385
packages/server/src/workspaces/git-status.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { spawn } from "child_process"
|
||||||
|
import { readFile } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
|
||||||
|
import type { LogLike } from "./git-worktrees"
|
||||||
|
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
|
||||||
|
|
||||||
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
type GitSuccessResult = Extract<GitResult, { ok: true }>
|
||||||
|
|
||||||
|
async function readFileAsDiffText(filePath: string): Promise<string> {
|
||||||
|
return readFile(filePath, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
|
||||||
|
const result = await resultPromise
|
||||||
|
if (!result.ok) {
|
||||||
|
return decodeGitShowResult(result, missingOk)
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||||
|
let stdout = ""
|
||||||
|
let stderr = ""
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString()
|
||||||
|
})
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
child.once("error", (error) => {
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
})
|
||||||
|
child.once("close", (code) => {
|
||||||
|
if (acceptedExitCodes.includes(code ?? 0)) {
|
||||||
|
resolve({ ok: true, stdout })
|
||||||
|
} else {
|
||||||
|
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||||
|
resolve({ ok: false, error, stdout, stderr })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
|
||||||
|
const existing = map.get(path)
|
||||||
|
if (existing) return existing
|
||||||
|
const next: WorktreeGitStatusEntry = {
|
||||||
|
path,
|
||||||
|
originalPath: null,
|
||||||
|
stagedStatus: null,
|
||||||
|
stagedAdditions: 0,
|
||||||
|
stagedDeletions: 0,
|
||||||
|
unstagedStatus: null,
|
||||||
|
unstagedAdditions: 0,
|
||||||
|
unstagedDeletions: 0,
|
||||||
|
}
|
||||||
|
map.set(path, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGitStatusPath(value: string): string {
|
||||||
|
return value.trim().replace(/\\+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitChangeKind(code: string): GitChangeKind | null {
|
||||||
|
const normalized = code.trim().toUpperCase()
|
||||||
|
if (!normalized) return null
|
||||||
|
if (normalized === "A") return "added"
|
||||||
|
if (normalized === "M") return "modified"
|
||||||
|
if (normalized === "D") return "deleted"
|
||||||
|
if (normalized.startsWith("R")) return "renamed"
|
||||||
|
if (normalized.startsWith("C")) return "copied"
|
||||||
|
if (normalized === "U") return "unmerged"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNameStatusOutput(
|
||||||
|
map: Map<string, WorktreeGitStatusEntry>,
|
||||||
|
output: string,
|
||||||
|
target: "stagedStatus" | "unstagedStatus",
|
||||||
|
) {
|
||||||
|
const tokens = output.split("\0")
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
while (index < tokens.length) {
|
||||||
|
const record = tokens[index++] ?? ""
|
||||||
|
if (!record) continue
|
||||||
|
|
||||||
|
const parts = record.split("\t")
|
||||||
|
const statusCode = parseGitChangeKind(parts[0] ?? "")
|
||||||
|
if (!statusCode) continue
|
||||||
|
|
||||||
|
const inlinePath = parts.slice(1).join("\t")
|
||||||
|
const firstPath = inlinePath || tokens[index++] || ""
|
||||||
|
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
|
||||||
|
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
|
||||||
|
const normalizedPath = normalizeGitStatusPath(path)
|
||||||
|
if (!normalizedPath) continue
|
||||||
|
const entry = ensureEntry(map, normalizedPath)
|
||||||
|
entry[target] = statusCode
|
||||||
|
if (statusCode === "renamed" || statusCode === "copied") {
|
||||||
|
const originalPath = normalizeGitStatusPath(firstPath)
|
||||||
|
entry.originalPath = originalPath || entry.originalPath || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
|
||||||
|
for (const rawLine of output.split(/\r?\n/)) {
|
||||||
|
const path = normalizeGitStatusPath(rawLine)
|
||||||
|
if (!path) continue
|
||||||
|
ensureEntry(map, path).unstagedStatus = "untracked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
|
||||||
|
for (const rawLine of output.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line) continue
|
||||||
|
const parts = rawLine.split("\t")
|
||||||
|
const isBinary = parts[0] === "-" || parts[1] === "-"
|
||||||
|
return {
|
||||||
|
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
|
||||||
|
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
|
||||||
|
isBinary,
|
||||||
|
found: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { additions: 0, deletions: 0, isBinary: false, found: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
|
||||||
|
const absolutePath = path.join(workspaceFolder, relativePath)
|
||||||
|
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSingleNumstat(result.stdout)
|
||||||
|
return { additions: parsed.additions, deletions: parsed.deletions }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
|
||||||
|
const pending = Array.from(map.values())
|
||||||
|
.filter((entry) => entry.unstagedStatus === "untracked")
|
||||||
|
.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
|
||||||
|
entry.unstagedAdditions = stats.additions
|
||||||
|
entry.unstagedDeletions = stats.deletions
|
||||||
|
} catch {
|
||||||
|
entry.unstagedAdditions = 0
|
||||||
|
entry.unstagedDeletions = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNumstatOutput(
|
||||||
|
map: Map<string, WorktreeGitStatusEntry>,
|
||||||
|
output: string,
|
||||||
|
target: "staged" | "unstaged",
|
||||||
|
) {
|
||||||
|
const tokens = output.split("\0")
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
while (index < tokens.length) {
|
||||||
|
const record = tokens[index++] ?? ""
|
||||||
|
if (!record) continue
|
||||||
|
|
||||||
|
const parts = record.split("\t")
|
||||||
|
if (parts.length < 3) continue
|
||||||
|
|
||||||
|
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
|
||||||
|
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
|
||||||
|
const inlinePath = parts.slice(2).join("\t")
|
||||||
|
const isRenameLike = inlinePath === ""
|
||||||
|
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
|
||||||
|
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
|
||||||
|
if (!normalizedPath) continue
|
||||||
|
|
||||||
|
const entry = ensureEntry(map, normalizedPath)
|
||||||
|
if (originalPath) {
|
||||||
|
entry.originalPath = originalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === "staged") {
|
||||||
|
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
|
||||||
|
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
|
||||||
|
} else {
|
||||||
|
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
|
||||||
|
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorktreeGitStatus(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<WorktreeGitStatusEntry[]> {
|
||||||
|
const { workspaceFolder, logger } = params
|
||||||
|
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
|
||||||
|
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
|
||||||
|
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
|
||||||
|
if (!result.ok) {
|
||||||
|
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedOutput = (stagedResult as GitSuccessResult).stdout
|
||||||
|
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
|
||||||
|
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
|
||||||
|
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
|
||||||
|
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
|
||||||
|
|
||||||
|
const entries = new Map<string, WorktreeGitStatusEntry>()
|
||||||
|
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
|
||||||
|
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
|
||||||
|
applyUntrackedOutput(entries, untrackedOutput)
|
||||||
|
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
|
||||||
|
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
|
||||||
|
await applyUntrackedFileStats(entries, workspaceFolder)
|
||||||
|
|
||||||
|
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeGitShowResult(result: GitResult, missingOk = false): string {
|
||||||
|
if (result.ok) return result.stdout
|
||||||
|
const message = result.stderr?.trim() || result.error.message || ""
|
||||||
|
if (
|
||||||
|
missingOk &&
|
||||||
|
(message.includes("exists on disk, but not in") ||
|
||||||
|
message.includes("Path '") ||
|
||||||
|
message.includes("does not exist") ||
|
||||||
|
message.includes("unknown revision or path not in the working tree"))
|
||||||
|
) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
|
||||||
|
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrackedDiffMetadata(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
normalizedPath: string
|
||||||
|
normalizedOriginalPath: string | null
|
||||||
|
}): Promise<{ isBinary: boolean; found: boolean }> {
|
||||||
|
const args = ["diff", "--numstat"]
|
||||||
|
if (params.scope === "staged") {
|
||||||
|
args.push("--cached")
|
||||||
|
}
|
||||||
|
args.push("--find-renames", "--find-copies", "--")
|
||||||
|
args.push(params.normalizedPath)
|
||||||
|
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
|
||||||
|
args.push(params.normalizedOriginalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runGit(args, params.workspaceFolder)
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSingleNumstat(result.stdout)
|
||||||
|
return { isBinary: parsed.isBinary, found: parsed.found }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUntrackedDiffMetadata(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
normalizedPath: string
|
||||||
|
}): Promise<{ isBinary: boolean }> {
|
||||||
|
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
|
||||||
|
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
|
||||||
|
if (!result.ok) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUnstagedBeforePath(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
normalizedPath: string
|
||||||
|
normalizedOriginalPath: string | null
|
||||||
|
}): Promise<GitResult> {
|
||||||
|
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
|
||||||
|
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
|
||||||
|
return currentPathResult
|
||||||
|
}
|
||||||
|
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorktreeGitDiff(params: {
|
||||||
|
workspaceFolder: string
|
||||||
|
path: string
|
||||||
|
originalPath?: string | null
|
||||||
|
scope: WorktreeGitDiffScope
|
||||||
|
}): Promise<WorktreeGitDiffResponse> {
|
||||||
|
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
|
||||||
|
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
|
||||||
|
|
||||||
|
const trackedMetadata = await getTrackedDiffMetadata({
|
||||||
|
workspaceFolder: params.workspaceFolder,
|
||||||
|
scope: params.scope,
|
||||||
|
normalizedPath,
|
||||||
|
normalizedOriginalPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffMetadata =
|
||||||
|
params.scope === "unstaged" && !trackedMetadata.found
|
||||||
|
? await getUntrackedDiffMetadata({
|
||||||
|
workspaceFolder: params.workspaceFolder,
|
||||||
|
normalizedPath,
|
||||||
|
})
|
||||||
|
: trackedMetadata
|
||||||
|
|
||||||
|
if (diffMetadata.isBinary) {
|
||||||
|
return {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: normalizedOriginalPath,
|
||||||
|
scope: params.scope,
|
||||||
|
before: "",
|
||||||
|
after: "",
|
||||||
|
isBinary: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.scope === "staged") {
|
||||||
|
const [beforeResult, afterResult] = await Promise.all([
|
||||||
|
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
|
||||||
|
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: normalizedOriginalPath,
|
||||||
|
scope: params.scope,
|
||||||
|
before: beforeResult,
|
||||||
|
after: afterResult,
|
||||||
|
isBinary: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexResult = await resolveUnstagedBeforePath({
|
||||||
|
workspaceFolder: params.workspaceFolder,
|
||||||
|
normalizedPath,
|
||||||
|
normalizedOriginalPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
|
||||||
|
let after = beforeResult
|
||||||
|
|
||||||
|
const fsPath = path.join(params.workspaceFolder, normalizedPath)
|
||||||
|
try {
|
||||||
|
after = await readFileAsDiffText(fsPath)
|
||||||
|
} catch {
|
||||||
|
after = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: normalizedPath,
|
||||||
|
originalPath: normalizedOriginalPath,
|
||||||
|
scope: params.scope,
|
||||||
|
before: beforeResult,
|
||||||
|
after,
|
||||||
|
isBinary: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ export interface LogLike {
|
|||||||
|
|
||||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||||
|
|
||||||
|
function isGitUnavailableResult(result: GitResult): boolean {
|
||||||
|
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
|
||||||
|
}
|
||||||
|
|
||||||
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||||
@@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
|
|||||||
|
|
||||||
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||||
|
if (isGitUnavailableResult(result)) {
|
||||||
|
throw new Error("Git is not installed or not available in PATH")
|
||||||
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||||
return { repoRoot: folder, isGitRepo: false }
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
|
|||||||
return { repoRoot, isGitRepo: true }
|
return { repoRoot, isGitRepo: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isGitAvailable(folder: string): Promise<boolean> {
|
||||||
|
const result = await runGit(["--version"], folder)
|
||||||
|
return result.ok || !isGitUnavailableResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
||||||
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||||
const lines = output.split(/\r?\n/)
|
const lines = output.split(/\r?\n/)
|
||||||
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
|
|||||||
logger?: LogLike
|
logger?: LogLike
|
||||||
}): Promise<WorktreeDescriptor[]> {
|
}): Promise<WorktreeDescriptor[]> {
|
||||||
const { repoRoot, workspaceFolder, logger } = params
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
|
||||||
|
|
||||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||||
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||||
return [rootDescriptor]
|
return [rootDescriptor]
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = parseWorktreePorcelain(result.stdout)
|
const records = parseWorktreePorcelain(result.stdout)
|
||||||
|
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
|
||||||
|
const rootDescriptor: WorktreeDescriptor = {
|
||||||
|
slug: "root",
|
||||||
|
directory: repoRoot,
|
||||||
|
kind: "root",
|
||||||
|
branch: rootRecord?.branch,
|
||||||
|
}
|
||||||
|
|
||||||
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
const seen = new Set<string>(["root"])
|
const seen = new Set<string>(["root"])
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { connect } from "net"
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import type { BinaryResolver } from "../settings/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
|||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
binaryResolver: BinaryResolver
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
@@ -83,10 +83,16 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||||
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
|
browser.writeFile(relativePath, contents)
|
||||||
|
}
|
||||||
|
|
||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryResolver.resolveDefault()
|
||||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
@@ -109,17 +115,14 @@ export class WorkspaceManager {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!descriptor.binaryVersion) {
|
|
||||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.workspaces.set(id, descriptor)
|
this.workspaces.set(id, descriptor)
|
||||||
|
|
||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const envVars = (serverConfig as any)?.environmentVariables
|
||||||
|
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||||
|
|
||||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
const opencodePassword = generateOpencodeServerPassword()
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
@@ -139,16 +142,22 @@ export class WorkspaceManager {
|
|||||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logLevel = (serverConfig as any)?.logLevel
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
|
logLevel,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||||
|
if (runtimeVersion) {
|
||||||
|
descriptor.binaryVersion = runtimeVersion
|
||||||
|
}
|
||||||
|
|
||||||
descriptor.pid = pid
|
descriptor.pid = pid
|
||||||
descriptor.port = port
|
descriptor.port = port
|
||||||
@@ -277,42 +286,12 @@ export class WorkspaceManager {
|
|||||||
return candidates[0] ?? ""
|
return candidates[0] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
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 async waitForWorkspaceReadiness(params: {
|
private async waitForWorkspaceReadiness(params: {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}) {
|
}): Promise<string | undefined> {
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.waitForPortAvailability(params.port),
|
this.waitForPortAvailability(params.port),
|
||||||
@@ -326,7 +305,7 @@ export class WorkspaceManager {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
await this.waitForInstanceHealth(params)
|
const version = await this.waitForInstanceHealth(params)
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||||
@@ -339,6 +318,8 @@ export class WorkspaceManager {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForInstanceHealth(params: {
|
private async waitForInstanceHealth(params: {
|
||||||
@@ -346,7 +327,7 @@ export class WorkspaceManager {
|
|||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}) {
|
}): Promise<string | undefined> {
|
||||||
const probeResult = await Promise.race([
|
const probeResult = await Promise.race([
|
||||||
this.probeInstance(params.workspaceId, params.port),
|
this.probeInstance(params.workspaceId, params.port),
|
||||||
params.exitPromise.then((info) => {
|
params.exitPromise.then((info) => {
|
||||||
@@ -360,7 +341,7 @@ export class WorkspaceManager {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (probeResult.ok) {
|
if (probeResult.ok) {
|
||||||
return
|
return probeResult.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestOutput = params.getLastOutput().trim()
|
const latestOutput = params.getLastOutput().trim()
|
||||||
@@ -371,8 +352,11 @@ export class WorkspaceManager {
|
|||||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
private async probeInstance(
|
||||||
const url = `http://127.0.0.1:${port}/project/current`
|
workspaceId: string,
|
||||||
|
port: number,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||||
|
const url = `http://127.0.0.1:${port}/global/health`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
@@ -383,11 +367,22 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
const response = await fetch(url, { headers })
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = `health probe returned HTTP ${response.status}`
|
const reason = `/global/health returned HTTP ${response.status}`
|
||||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
return { ok: false, reason }
|
return { ok: false, reason }
|
||||||
}
|
}
|
||||||
return { ok: true }
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
||||||
|
const healthy = payload?.healthy === true
|
||||||
|
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
||||||
|
|
||||||
|
if (!healthy) {
|
||||||
|
const reason = "Instance reported unhealthy"
|
||||||
|
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
||||||
|
return { ok: false, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, version: version || undefined }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||||
|
|||||||
@@ -4,43 +4,10 @@ import path from "path"
|
|||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
|
||||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
|
||||||
|
|
||||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = path.extname(binaryPath).toLowerCase()
|
|
||||||
|
|
||||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
|
||||||
const comspec = process.env.ComSpec || "cmd.exe"
|
|
||||||
// cmd.exe requires the full command as a single string.
|
|
||||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
|
||||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: comspec,
|
|
||||||
args: ["/d", "/s", "/c", commandLine],
|
|
||||||
options: { windowsVerbatimArguments: true } as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
|
||||||
// powershell.exe ships with Windows. (pwsh may not.)
|
|
||||||
return {
|
|
||||||
command: "powershell.exe",
|
|
||||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
|
||||||
options: {} as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
|
||||||
|
|
||||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
const redacted: Record<string, string | undefined> = {}
|
const redacted: Record<string, string | undefined> = {}
|
||||||
@@ -59,6 +26,7 @@ interface LaunchOptions {
|
|||||||
folder: string
|
folder: string
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
environment?: Record<string, string>
|
environment?: Record<string, string>
|
||||||
|
logLevel?: string
|
||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +40,10 @@ export interface ProcessExitInfo {
|
|||||||
interface ManagedProcess {
|
interface ManagedProcess {
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
requestedStop: boolean
|
requestedStop: boolean
|
||||||
|
wsl?: {
|
||||||
|
distro: string
|
||||||
|
linuxPid: number | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkspaceRuntime {
|
export class WorkspaceRuntime {
|
||||||
@@ -82,7 +54,8 @@ export class WorkspaceRuntime {
|
|||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
this.validateFolder(options.folder)
|
||||||
|
|
||||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||||
|
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||||
@@ -108,7 +81,13 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||||
|
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||||
|
cwd: options.folder,
|
||||||
|
env,
|
||||||
|
propagateEnvKeys: propagatedEnvKeys,
|
||||||
|
wslPidMarker: WSL_PID_MARKER,
|
||||||
|
})
|
||||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
@@ -138,14 +117,18 @@ export class WorkspaceRuntime {
|
|||||||
)
|
)
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: spec.cwd,
|
||||||
env,
|
env: spec.env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached,
|
detached,
|
||||||
...spec.options,
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const managed: ManagedProcess = { child, requestedStop: false }
|
const managed: ManagedProcess = {
|
||||||
|
child,
|
||||||
|
requestedStop: false,
|
||||||
|
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||||
|
}
|
||||||
this.processes.set(options.workspaceId, managed)
|
this.processes.set(options.workspaceId, managed)
|
||||||
|
|
||||||
let stdoutBuffer = ""
|
let stdoutBuffer = ""
|
||||||
@@ -225,6 +208,15 @@ export class WorkspaceRuntime {
|
|||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
|
||||||
|
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
|
||||||
|
if (Number.isFinite(linuxPid) && linuxPid > 0) {
|
||||||
|
managed.wsl.linuxPid = linuxPid
|
||||||
|
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
recentStdout.push(trimmed)
|
recentStdout.push(trimmed)
|
||||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||||
recentStdout.shift()
|
recentStdout.shift()
|
||||||
@@ -339,11 +331,44 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||||
|
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||||
|
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||||
|
const exitCode = result.status
|
||||||
|
if (exitCode === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||||
|
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||||
|
const combined = `${stdout}\n${stderr}`
|
||||||
|
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||||
|
"WSL kill failed",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// Best-effort: terminate the whole process tree rooted at pid.
|
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||||
// Use /F only for escalation.
|
if (!trySignalWslProcess(signal)) {
|
||||||
tryTaskkill(signal === "SIGKILL")
|
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||||
|
tryTaskkill(signal === "SIGKILL")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
307
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||||
|
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||||
|
|
||||||
|
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||||
|
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||||
|
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||||
|
|
||||||
|
export interface SpawnSpec {
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
options: {
|
||||||
|
windowsVerbatimArguments?: boolean
|
||||||
|
}
|
||||||
|
cwd?: string
|
||||||
|
env?: NodeJS.ProcessEnv
|
||||||
|
wsl?: {
|
||||||
|
distro: string
|
||||||
|
pidMarker?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildSpawnSpecOptions {
|
||||||
|
cwd?: string
|
||||||
|
env?: NodeJS.ProcessEnv
|
||||||
|
propagateEnvKeys?: string[]
|
||||||
|
wslPidMarker?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WslPath {
|
||||||
|
distro: string
|
||||||
|
linuxPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WslWorkingDirectory =
|
||||||
|
| { kind: "linux"; path: string }
|
||||||
|
| { kind: "windows"; path: string }
|
||||||
|
|
||||||
|
export function parseWslUncPath(input: string): WslPath | null {
|
||||||
|
const normalized = input.trim().replace(/\//g, "\\")
|
||||||
|
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const distro = match[1] ?? ""
|
||||||
|
const remainder = match[2] ?? ""
|
||||||
|
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
distro,
|
||||||
|
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
|
||||||
|
const wslFolder = parseWslUncPath(folder)
|
||||||
|
if (wslFolder) {
|
||||||
|
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsFolder = normalizeWindowsPath(folder)
|
||||||
|
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||||
|
const wslPath = parseWslUncPath(binaryPath)
|
||||||
|
if (wslPath) {
|
||||||
|
return buildWslSpawnSpec(wslPath, args, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(binaryPath).toLowerCase()
|
||||||
|
|
||||||
|
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
// cmd.exe requires the full command as a single string.
|
||||||
|
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||||
|
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: comspec,
|
||||||
|
args: ["/d", "/s", "/c", commandLine],
|
||||||
|
options: { windowsVerbatimArguments: true },
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||||
|
// powershell.exe ships with Windows. (pwsh may not.)
|
||||||
|
return {
|
||||||
|
command: "powershell.exe",
|
||||||
|
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: binaryPath,
|
||||||
|
args,
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return {
|
||||||
|
command: binaryPath,
|
||||||
|
args,
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
|
||||||
|
return {
|
||||||
|
command: "wsl.exe",
|
||||||
|
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
|
||||||
|
options: {},
|
||||||
|
wsl: { distro },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeBinaryVersion(binaryPath: string): {
|
||||||
|
valid: boolean
|
||||||
|
version?: string
|
||||||
|
reported?: string
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
if (!binaryPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
cwd: spec.cwd,
|
||||||
|
env: spec.env,
|
||||||
|
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdoutLines = String(result.stdout ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
const stderrLines = String(result.stderr ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||||
|
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||||
|
if (!reported) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionMatch = reported.match(VERSION_REGEX)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
return { valid: true, version, reported }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||||
|
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||||
|
if (options.cwd && !workingDirectory) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wslArgs = ["--distribution", wslPath.distro]
|
||||||
|
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
|
||||||
|
|
||||||
|
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
|
||||||
|
wslArgs.push("--cd", workingDirectory.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldWrapWithShell) {
|
||||||
|
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
|
||||||
|
wslArgs.push(
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
launchScript,
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
)
|
||||||
|
if (workingDirectory) {
|
||||||
|
wslArgs.push(workingDirectory.path)
|
||||||
|
}
|
||||||
|
wslArgs.push(
|
||||||
|
wslPath.linuxPath,
|
||||||
|
...args,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: "wsl.exe",
|
||||||
|
args: wslArgs,
|
||||||
|
options: {},
|
||||||
|
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||||
|
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
|
||||||
|
const steps: string[] = []
|
||||||
|
|
||||||
|
if (pidMarker) {
|
||||||
|
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingDirectory?.kind === "linux") {
|
||||||
|
steps.push('cd "$1"')
|
||||||
|
steps.push("shift")
|
||||||
|
} else if (workingDirectory?.kind === "windows") {
|
||||||
|
steps.push('cd "$(wslpath -au "$1")"')
|
||||||
|
steps.push("shift")
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push('exec "$@"')
|
||||||
|
return steps.join(" && ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWindowsPath(input: string): string | null {
|
||||||
|
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||||
|
if (!env) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToPropagate = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
|
||||||
|
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
if (keysToPropagate.length === 0) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...env }
|
||||||
|
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||||
|
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||||
|
|
||||||
|
for (const key of keysToPropagate) {
|
||||||
|
const existingEntry = byName.get(key)
|
||||||
|
if (existingEntry) {
|
||||||
|
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.WSLENV = Array.from(byName.values()).join(":")
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
|
||||||
|
if (!requiresPathTranslation) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const [name, rawFlags = ""] = entry.split("/")
|
||||||
|
if (rawFlags.includes("p")) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
|
||||||
|
}
|
||||||
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { realpath } from "fs/promises"
|
||||||
|
import type { LogLike } from "./git-worktrees"
|
||||||
|
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
|
||||||
|
|
||||||
|
type WorktreeCacheEntry = {
|
||||||
|
expiresAt: number
|
||||||
|
repoRoot: string
|
||||||
|
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKTREE_CACHE_TTL_MS = 2000
|
||||||
|
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||||
|
|
||||||
|
async function normalizeDirectoryPath(directory: string): Promise<string> {
|
||||||
|
const trimmed = (directory ?? "").trim()
|
||||||
|
if (!trimmed) return ""
|
||||||
|
try {
|
||||||
|
return await realpath(trimmed)
|
||||||
|
} catch {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
|
||||||
|
const cached = worktreeCache.get(params.workspaceId)
|
||||||
|
const now = Date.now()
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||||
|
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||||
|
const entry: WorktreeCacheEntry = {
|
||||||
|
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||||
|
repoRoot,
|
||||||
|
worktrees: await Promise.all(
|
||||||
|
worktrees.map(async (wt) => ({
|
||||||
|
slug: wt.slug,
|
||||||
|
directory: wt.directory,
|
||||||
|
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
worktreeCache.set(params.workspaceId, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWorktreeDirectory(params: {
|
||||||
|
workspaceId: string
|
||||||
|
workspacePath: string
|
||||||
|
worktreeSlug: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const cached = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
|
||||||
|
if (match) {
|
||||||
|
return match.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreeCache.delete(params.workspaceId)
|
||||||
|
const refreshed = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWorktreeSlugForDirectory(params: {
|
||||||
|
workspaceId: string
|
||||||
|
workspacePath: string
|
||||||
|
directory: string
|
||||||
|
logger?: LogLike
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const target = await normalizeDirectoryPath(params.directory ?? "")
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
const cached = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
|
||||||
|
if (match) {
|
||||||
|
return match.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
worktreeCache.delete(params.workspaceId)
|
||||||
|
const refreshed = await getCachedWorktrees({
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
workspacePath: params.workspacePath,
|
||||||
|
logger: params.logger,
|
||||||
|
})
|
||||||
|
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
|
||||||
|
}
|
||||||
2796
packages/tauri-app/Cargo.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
"dev:prep": "node ./scripts/dev-prep.js",
|
"dev:prep": "node ./scripts/dev-prep.js",
|
||||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||||
|
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||||
"prebuild": "node ./scripts/prebuild.js",
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
"bundle:server": "npm run prebuild",
|
"bundle:server": "npm run prebuild",
|
||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const serverDevInstallCommand =
|
|||||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -36,6 +37,12 @@ const braceExpansionPath = path.join(
|
|||||||
"package.json",
|
"package.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const serverBuildDependencyPaths = [
|
||||||
|
path.join(serverRoot, "node_modules", "typescript", "package.json"),
|
||||||
|
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
|
||||||
|
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
|
||||||
|
]
|
||||||
|
|
||||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||||
|
|
||||||
async function ensureMonacoAssets() {
|
async function ensureMonacoAssets() {
|
||||||
@@ -55,11 +62,7 @@ async function ensureMonacoAssets() {
|
|||||||
function ensureServerBuild() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist")
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public")
|
const publicPath = path.join(serverRoot, "public")
|
||||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[prebuild] server build missing; running workspace build...")
|
|
||||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
@@ -91,8 +94,17 @@ function ensureUiBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncServerUiBundle() {
|
||||||
|
console.log("[prebuild] syncing server public UI bundle...")
|
||||||
|
execSync(serverPrepareUiCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +148,7 @@ function ensureRollupPlatformBinary() {
|
|||||||
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||||
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||||
"darwin-x64": "@rollup/rollup-darwin-x64",
|
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||||
|
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
|
||||||
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +259,7 @@ function copyUiLoadingAssets() {
|
|||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
|
syncServerUiBundle()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const packageJsonPath = path.join(root, "package.json")
|
||||||
|
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
|
||||||
|
const cargoLockPath = path.join(root, "Cargo.lock")
|
||||||
|
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
|
||||||
|
|
||||||
|
function readPackageVersion() {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
|
||||||
|
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||||
|
throw new Error("Missing version in packages/tauri-app/package.json")
|
||||||
|
}
|
||||||
|
return packageJson.version
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCargoToml(version) {
|
||||||
|
const current = fs.readFileSync(cargoTomlPath, "utf8")
|
||||||
|
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
|
||||||
|
const match = current.match(packageVersionPattern)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[2] === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||||
|
fs.writeFileSync(cargoTomlPath, updated)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCargoLock(version) {
|
||||||
|
if (!fs.existsSync(cargoLockPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = fs.readFileSync(cargoLockPath, "utf8")
|
||||||
|
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
|
||||||
|
const match = current.match(packageVersionPattern)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[2] === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||||
|
fs.writeFileSync(cargoLockPath, updated)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTauriConfig(version) {
|
||||||
|
const current = fs.readFileSync(tauriConfigPath, "utf8")
|
||||||
|
const config = JSON.parse(current)
|
||||||
|
if (config.version === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
config.version = version
|
||||||
|
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const version = readPackageVersion()
|
||||||
|
const changed = []
|
||||||
|
|
||||||
|
if (syncCargoToml(version)) {
|
||||||
|
changed.push(path.relative(root, cargoTomlPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncCargoLock(version)) {
|
||||||
|
changed.push(path.relative(root, cargoLockPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncTauriConfig(version)) {
|
||||||
|
changed.push(path.relative(root, tauriConfigPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed.length === 0) {
|
||||||
|
console.log(`[sync-tauri-version] already aligned to ${version}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[sync-tauri-version] failed: ${message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.1.0"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -12,16 +12,24 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
base64 = "0.22"
|
||||||
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
once_cell = "1"
|
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
thiserror = "1"
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
keepawake = "0.6"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-global-shortcut = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
tauri-plugin-keepawake = "0.1.1"
|
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
webkit2gtk = "2.0.2"
|
||||||
|
|||||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
|
"opener:allow-open-url",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
|
|||||||
@@ -1 +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:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
{"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:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2379,34 +2379,70 @@
|
|||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:default",
|
"const": "global-shortcut:default",
|
||||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the start command without any pre-configured scope.",
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:allow-start",
|
"const": "global-shortcut:allow-is-registered",
|
||||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the stop command without any pre-configured scope.",
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:allow-stop",
|
"const": "global-shortcut:allow-register",
|
||||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the start command without any pre-configured scope.",
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:deny-start",
|
"const": "global-shortcut:allow-register-all",
|
||||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the stop command without any pre-configured scope.",
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:deny-stop",
|
"const": "global-shortcut:allow-unregister",
|
||||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
|||||||
@@ -2379,34 +2379,70 @@
|
|||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:default",
|
"const": "global-shortcut:default",
|
||||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the start command without any pre-configured scope.",
|
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:allow-start",
|
"const": "global-shortcut:allow-is-registered",
|
||||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the stop command without any pre-configured scope.",
|
"description": "Enables the register command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:allow-stop",
|
"const": "global-shortcut:allow-register",
|
||||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the start command without any pre-configured scope.",
|
"description": "Enables the register_all command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:deny-start",
|
"const": "global-shortcut:allow-register-all",
|
||||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the stop command without any pre-configured scope.",
|
"description": "Enables the unregister command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "keepawake:deny-stop",
|
"const": "global-shortcut:allow-unregister",
|
||||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:allow-unregister-all",
|
||||||
|
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-is-registered",
|
||||||
|
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register",
|
||||||
|
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-register-all",
|
||||||
|
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister",
|
||||||
|
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "global-shortcut:deny-unregister-all",
|
||||||
|
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
|||||||
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/128x128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/256x256.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/32x32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/48x48.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/512x512.png
Normal file
|
After Width: | Height: | Size: 322 KiB |